From 82998bcd494d180f64679fa9b1c77436f51101c3 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 15 Jul 2026 22:43:56 +0200 Subject: [PATCH 1/3] docs(skills): one installable prisma-composer skill replacing the two-skill set Merge deploying-prisma-apps and testing-prisma-apps into a single self-contained skill, skills/prisma-composer, and add the missing authoring story: the mental model, contracts, the root module, builds, deploy config, databases (postgres/pnPostgres), reusable modules (cron/storage/streams), config params, secrets, and production pitfalls, plus an honest list of what Composer doesn't do yet. Fixes carried by the merge: - folder names now match frontmatter names (the runtimes key on frontmatter; the old folders said prisma-apps, the frontmatter said prisma-composers) - no link resolves outside the skill folder (the old testing skill linked ../../docs/design, which dangles once installed into another repo) - standaloneEntryPath corrected to standaloneServerPath (the export that actually ships) skills/README.md documents the install command and the authoring rules (verify every claim against packages/ while drafting; self-contained; concepts over procedures). Signed-off-by: willbot Signed-off-by: Will Madden --- skills/README.md | 47 +++ skills/deploying-prisma-apps/SKILL.md | 106 ------ skills/prisma-composer/SKILL.md | 472 ++++++++++++++++++++++++++ skills/testing-prisma-apps/SKILL.md | 110 ------ 4 files changed, 519 insertions(+), 216 deletions(-) create mode 100644 skills/README.md delete mode 100644 skills/deploying-prisma-apps/SKILL.md create mode 100644 skills/prisma-composer/SKILL.md delete mode 100644 skills/testing-prisma-apps/SKILL.md diff --git a/skills/README.md b/skills/README.md new file mode 100644 index 00000000..6b6c2410 --- /dev/null +++ b/skills/README.md @@ -0,0 +1,47 @@ +# Prisma Composer skills + +Agent skills for [Prisma Composer](https://github.com/prisma/composer) — one +`SKILL.md` that teaches an LLM agent how to write, test, and deploy a Prisma +App without re-deriving the API from documentation each time. + +## What's in the box + +One skill, `prisma-composer`, covering the whole story: the mental model +(Modules, `compute()`, `service.load()`), RPC contracts, databases, reusable +modules (cron/storage/streams), config params, secrets, testing +(`mockService`/`bootstrapService`), deploying (`prisma-composer deploy`, +stages, destroy), and the production pitfalls. Composer's surface is small +enough for one skill; there is no router or per-topic cluster. + +## Install + +```bash +npx skills add prisma/composer --skill prisma-composer +``` + +The [`skills` CLI](https://npmjs.com/package/skills) installs it at the +project level for the agent runtimes it detects (`-a ` to pick one). +Install the git ref matching your `@prisma/composer` version — the skill's +surface tracks this repo's packages. + +## Authoring rules + +For anyone editing the skill: + +- **Verify every claim while drafting, not in a final pass.** Every import + must resolve against a `packages/9-public/*` export map, and every CLI + flag/command against `packages/0-framework/3-tooling/cli/`. If ripgrep finds + nothing, the surface doesn't ship — name it under *What Composer doesn't do + yet* instead of extrapolating. +- **The skill must be self-contained.** It gets installed into other repos, so + no link may resolve outside `skills/prisma-composer/`. Repo docs may be + named in prose ("`docs/design/10-domains/testing.md` in the prisma/composer + repo"), never linked relatively. +- **Teach concepts, not procedures.** Name the moving parts and the command + that reveals each piece of state; reserve numbered steps for one-safe-path + operations. +- **Folder name and frontmatter `name` must match** — the runtimes key on the + frontmatter, humans on the folder. + +Maintainer-facing skills (release process, commit conventions) live in +[`../skills-contrib/`](../skills-contrib/), not here. diff --git a/skills/deploying-prisma-apps/SKILL.md b/skills/deploying-prisma-apps/SKILL.md deleted file mode 100644 index bfa56442..00000000 --- a/skills/deploying-prisma-apps/SKILL.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -name: deploying-prisma-composers -description: >- - How to deploy a Prisma App to production with `prisma-composer deploy`, stand up - an isolated staging or per-PR preview environment with `--stage`, and tear - environments down with `prisma-composer destroy`. Destroy always requires an - explicit target — a bare `prisma-composer destroy` is an error. Use when - deploying a Prisma App, creating a staging/preview environment, or removing - a deployed environment. Triggers on "prisma-composer deploy", "deploy a prisma - app", "--stage", "staging environment", "preview environment", "prisma-composer - destroy", "tear down a stage". ---- - -# Deploying Prisma Apps - -`prisma-composer` deploys the app whose root node is your entry file's default -export. Two commands, `deploy` and `destroy`; the target environment — called -a **stage** — is chosen on the command line, never in your code. - -| You want to… | Run | -| --- | --- | -| Deploy to production | `prisma-composer deploy ` | -| Deploy an isolated environment | `prisma-composer deploy --stage ` | -| Tear down an isolated environment | `prisma-composer destroy --stage ` | -| Tear down production's resources | `prisma-composer destroy --production` | - -## The commands - -```sh -prisma-composer deploy module.ts # production -prisma-composer deploy module.ts --stage staging # an isolated "staging" environment -prisma-composer deploy module.ts --stage pr-42 # one isolated environment per PR -prisma-composer deploy module.ts --name demo-42 # override the app name for this run -``` - -A deploy needs exactly two environment variables: `PRISMA_SERVICE_TOKEN` and -`PRISMA_WORKSPACE_ID`. Nothing else — the CLI resolves the app's Project (by -the root module's name) and, for a named stage, the stage's Branch, creating -either if it doesn't exist yet. A fresh checkout with just those two variables -set deploys successfully. - -Build your app first — `prisma-composer deploy` does not build for you: - -```sh -turbo run build && prisma-composer deploy module.ts -``` - -Re-deploying the same stage is idempotent: it finds the existing Project and -Branch and updates the resources inside them. - -## What a stage is - -Same code, many environments. Deploying with no `--stage` targets -**production**, at the Project level. `--stage ` targets a **named -stage** — a Branch of that same Project, with its own compute, its own empty -database, and its own configuration. Every environment repeats the app's full -topology; only the data and config differ. - -A stage name must be a valid git ref name (checked with `git -check-ref-format`); an invalid name is a hard error, never silently -normalized. - -## Destroy requires an explicit target - -A bare `prisma-composer destroy` is an error. - -```sh -prisma-composer destroy module.ts -# error: `destroy` requires an explicit target: --stage to tear down a -# branch environment, or --production to tear down the production environment. -``` - -Name what you're tearing down: - -```sh -prisma-composer destroy module.ts --stage staging # removes staging's resources, then its Branch -prisma-composer destroy module.ts --production # removes production's resources -``` - -`--stage` and `--production` together is also an error — pick one. Destroying -a named stage deletes its Branch after removing its resources; the production -Branch itself is never deleted, only the resources inside it. - -Destroy never creates anything: destroying a stage that was never deployed -fails with "nothing deployed for `/`" rather than standing one up -first. - -## Example: `storefront-auth` - -```sh -cd examples/storefront-auth - -prisma-composer deploy module.ts # production: its own URL, its own database -prisma-composer deploy module.ts --stage staging # staging: a second, isolated URL and database - -prisma-composer destroy module.ts --stage staging # tears down staging only — production is untouched -``` - -## Full model - -- [`docs/design/10-domains/deploy-cli.md`](../../docs/design/10-domains/deploy-cli.md) - — the pipeline, stages and containers, the destroy contract. -- [ADR-0023](../../docs/design/90-decisions/ADR-0023-a-prisma-app-is-one-project-a-stage-is-a-branch.md) - — a Prisma App is one Project; a stage is a Branch. -- [ADR-0024](../../docs/design/90-decisions/ADR-0024-a-stage-is-a-deploy-time-environment-resolved-to-project-and-branch.md) - — how a stage resolves to a Project and Branch. diff --git a/skills/prisma-composer/SKILL.md b/skills/prisma-composer/SKILL.md new file mode 100644 index 00000000..5833566a --- /dev/null +++ b/skills/prisma-composer/SKILL.md @@ -0,0 +1,472 @@ +--- +name: prisma-composer +description: >- + How to write, test, and deploy an app with Prisma Composer + (`@prisma/composer`): declare services with `compute()` and typed + dependencies, define RPC contracts, compose Modules, read config and + secrets, use the shared cron/storage/streams modules, test with + `mockService`/`bootstrapService`, and deploy with `prisma-composer deploy` + (stages, destroy). Use when building a Prisma App, wiring a service + dependency, adding a Postgres database, writing tests for composed + services, or deploying/tearing down an environment. Triggers on + "prisma composer", "@prisma/composer", "prisma app", "compute()", + "service.load()", "module()", "contract()", "mockService", + "bootstrapService", "prisma-composer deploy", "--stage", + "prisma-composer destroy". +--- + +# Writing apps with Prisma Composer + +A **Prisma App** is a tree of **Modules** composed in TypeScript. The leaves +are **services** (`compute()`) and **resources** (`postgres()`); the root +module wires them together by their typed ports. Your code receives everything +from exactly one place — the service node: + +- `service.load()` — dependencies (typed RPC clients, database bindings) +- `service.config()` — config params (validated, typed values) +- `service.secrets()` — secret values (redacting `SecretBox`es) + +The framework never bundles or transforms your code. You build your app +(`tsdown`, `next build`); `prisma-composer deploy` assembles the built output +and provisions it on Prisma Cloud (Compute + Prisma Postgres). + +Two packages, and only two, appear in your `package.json`: + +| Package | Provides | +| --- | --- | +| `@prisma/composer` | Core authoring: `module`, `secret`, params, `/rpc`, `/node`, `/nextjs`, `/config`, `/testing`, `/tsdown`, the `prisma-composer` CLI | +| `@prisma/composer-prisma-cloud` | The Prisma Cloud target: `compute`, `postgres`, `envSecret`, `/control`, `/testing`, and the shared `/cron`, `/storage`, `/streams`, `/prisma-next` modules | + +## Anatomy of a service + +A service is four small files. Worked example: an `auth` service that owns a +Postgres database and serves an RPC contract, consumed by a `storefront` +Next.js app. + +**The contract** lives with the service that owns it. Any Standard Schema +validator types the messages; arktype is the house choice: + +```ts +// auth/src/contract.ts +import { contract, rpc } from '@prisma/composer/rpc'; +import { type } from 'arktype'; + +export const authContract = contract({ + verify: rpc({ input: type({ token: 'string' }), output: type({ ok: 'boolean' }) }), +}); +``` + +**The service declaration** is pure data — name, dependencies, build, exposed +ports. No behavior, no platform keys: + +```ts +// auth/src/service.ts +import node from '@prisma/composer/node'; +import { compute, postgres } from '@prisma/composer-prisma-cloud'; +import { authContract } from './contract.ts'; + +export default compute({ + name: 'auth', + deps: { db: postgres() }, + build: node({ module: import.meta.url, entry: '../dist/server.mjs' }), + expose: { rpc: authContract }, +}); +``` + +**The server entry** is what your build produces and the platform boots. It +reads its dependencies through `load()` and serves the contract with +`serve()` — the handler map is keyed by the expose port's name and is +exhaustive at compile time: + +```ts +// auth/src/server.ts +import { serve } from '@prisma/composer/rpc'; +import { SQL } from 'bun'; +import service from './service.ts'; + +const { db } = service.load(); // { url } — you build your own client +const { port } = service.config(); // params, separate namespace from deps + +const sql = new SQL({ url: db.url, max: 1, idleTimeout: 10 }); + +const handler = serve(service, { + rpc: { + verify: async ({ token }) => ({ ok: token.length > 0 }), + }, +}); +export default handler; + +// Bind all interfaces — Compute routes external HTTP to the VM; a +// loopback-only listener is unreachable. +Bun.serve({ port, hostname: '0.0.0.0', fetch: handler }); +``` + +**The consumer** declares the dependency as `rpc(contract)` and gets a typed +client back from `load()`: + +```ts +// storefront/src/service.ts +import nextjs from '@prisma/composer/nextjs'; +import { rpc } from '@prisma/composer/rpc'; +import { compute } from '@prisma/composer-prisma-cloud'; +import { authContract } from '@my-app/auth/contract'; + +export default compute({ + name: 'storefront', + deps: { auth: rpc(authContract) }, + build: nextjs({ module: import.meta.url, appDir: '..' }), +}); +``` + +```tsx +// storefront/app/page.tsx +import service from '../src/service.ts'; + +// load() reads the runtime environment, which doesn't exist at build time — +// render per request instead of prerendering. +export const dynamic = 'force-dynamic'; + +export default async function Home() { + const { auth } = service.load(); + const { ok } = await auth.verify({ token: 'demo-token' }); + return

Signed in: {String(ok)}

; +} +``` + +## The root module + +The root module provisions the pieces and wires exposed ports into dependency +slots. It is the app — `prisma-composer deploy` loads its default export: + +```ts +// module.ts +import { module } from '@prisma/composer'; +import authModule from '@my-app/auth'; +import storefrontService from '@my-app/storefront'; + +export default module('my-app', ({ provision }) => { + const auth = provision(authModule); + provision(storefrontService, { deps: { auth: auth.rpc } }); +}); +``` + +`provision(node, opts?)` accepts `id` (defaults to the node's own name), +`deps` (wire each declared dependency to a provisioned ref or exposed port), +and `secrets` (bind secret needs — see § Secrets). + +## Builds are yours + +The framework assembles only what you built (ADR-0005: users build, the +framework assembles). For a plain server process, build each entry +self-contained with the shipped tsdown preset: + +```ts +// tsdown.config.ts +import { prismaTsDownConfig } from '@prisma/composer/tsdown'; +export default prismaTsDownConfig({ entry: { server: 'src/server.ts' }, outDir: 'dist' }); +``` + +Two services in one package means two separate builds into separate `outDir`s +— not one multi-entry build, which would split shared code into a chunk +neither dist contains. For Next.js, `next build` with `output: 'standalone'` +is the whole build; `nextjs({ module, appDir })` tells the deploy where the +app root is. + +Always build before deploying — `prisma-composer deploy` does not build for +you. + +## Deploy config + +`prisma-composer.config.ts` sits next to `module.ts`. It is read only by +`prisma-composer deploy`/`destroy`, never imported by app code: + +```ts +// prisma-composer.config.ts +import { defineConfig } from '@prisma/composer/config'; +import { nodeBuild } from '@prisma/composer/node/control'; +import { prismaCloud, prismaState } from '@prisma/composer-prisma-cloud/control'; + +export default defineConfig({ + extensions: [prismaCloud(), nodeBuild()], + state: () => prismaState(), // workspace-hosted deploy state, shared by every deployer +}); +``` + +Add `nextjsBuild()` from `@prisma/composer/nextjs/control` to `extensions` +when the app contains a Next.js service. + +## Databases + +Two kinds of Postgres dependency: + +**`postgres()`** — the binding is `{ url }` and the app owns its client +(ADR-0015). Construct it in your server entry, as in the auth example above. + +**`pnPostgres(...)`** — a Prisma Next-typed database (ADR-0022): `load()` +returns the typed client the framework constructs from your data contract, so +queries like `db.orm.public.Product.all()` are compile-time checked. The +contract is emitted from `contract.prisma` by `prisma-next contract emit` and +wrapped once, referenced by both ends: + +```ts +// src/data.ts — the ONE value both ends reference +import { pnContract } from '@prisma/composer-prisma-cloud/prisma-next'; +import type { Contract } from '../contract.d.ts'; +import contractJson from '../contract.json' with { type: 'json' }; + +export const catalogData = pnContract(contractJson); +``` + +The dependency end is `deps: { db: pnPostgres(catalogData) }`. The resource +end (inside the module that owns the database) also names the +`prisma-next.config.ts` path, which the deploy's migration step loads to find +`migrations/` — migrations are applied at deploy, before the service starts: + +```ts +const db = provision(pnPostgres({ name: 'database', contract: catalogData, config })); +``` + +See `examples/store/modules/catalog` in the prisma/composer repo for the +complete pattern. + +## Reusable Modules + +A Module is the unit of reuse: it owns its internals (its database, its +services) and exposes only typed ports. Declare the boundary in the second +argument; wire internals in the builder; return the exposed ports: + +```ts +// auth/src/module.ts — a Module that owns its own Postgres +import { module, secret } from '@prisma/composer'; +import { postgres } from '@prisma/composer-prisma-cloud'; +import { authContract } from './contract.ts'; +import authService from './service.ts'; + +export default module( + 'auth', + { secrets: { signingKey: secret() }, expose: { rpc: authContract } }, + ({ secrets, provision }) => { + const db = provision(postgres({ name: 'database' })); + const service = provision(authService, { + id: 'service', + deps: { db }, + secrets: { signingKey: secrets.signingKey }, + }); + return { rpc: service.rpc }; + }, +); +``` + +Naming rules that bite: a provision id shorter than 3 characters is rejected +by the platform (name the database `'database'`, not `'db'`), and a service +whose name equals its enclosing module's reads as `auth.auth` unless you give +it an explicit `id`. + +A module can also declare boundary `deps` — inputs the parent wires exactly as +it would wire a service's. The consumer never sees the module's internals. + +### Shared modules + +First-party Modules ship under `@prisma/composer-prisma-cloud`: + +| Import | What it provisions | Exposes | +| --- | --- | --- | +| `cron` from `/cron` | An always-on scheduler firing your schedule at your runner service | nothing | +| `storage` from `/storage` | An S3-backed blob store (own Postgres + minted credentials) | `store` | +| `streams` from `/streams` | Durable append-only event streams over a `store` | `streams` | + +Cron end to end — the schedule is one source of truth; `serveSchedule` is +exhaustive over its job ids at compile time: + +```ts +// service.ts +import { defineSchedule, triggerContract } from '@prisma/composer-prisma-cloud/cron'; +export const schedule = defineSchedule({ tick: '60s' }); +// the runner service exposes { trigger: triggerContract } + +// server.ts +import { serveSchedule } from '@prisma/composer-prisma-cloud/cron'; +const handler = serveSchedule(service, schedule, { + tick: (deps) => deps.worker.tick({}), +}); + +// module.ts — the cron module's boundary deps mirror the runner's own +provision(cron({ schedule, runner: runnerService }), { deps: { worker: worker.rpc } }); +``` + +## Config params + +Params are caller-owned schemas on the declaration (ADR-0018/0021). `string()` +and `number()` cover the scalars; `param(schema)` wraps anything else: + +```ts +import { param, string } from '@prisma/composer'; +import { type } from 'arktype'; + +compute({ + name: 'scheduler', + params: { + jobs: param(type({ jobId: 'string', every: 'string' }).array()), + region: string({ optional: true }), + }, + // ... +}); +``` + +Read them with `service.config()` — never `load()`; deps and params are +separate namespaces. Facets are `default` and `optional`. Every service has a +reserved `port` param (default 3000); declaring your own `port` is an +authoring error. + +## Secrets + +Secret values never travel through framework config (ADR-0029). A service +declares a nameless **need**; the root binds it to a platform env-var name; +the value stays only in that platform variable: + +```ts +// service.ts — the need +import { secret } from '@prisma/composer'; +compute({ /* ... */ secrets: { signingKey: secret() } }); + +// module.ts root — the binding +import { envSecret } from '@prisma/composer-prisma-cloud'; +provision(authModule, { secrets: { signingKey: envSecret('AUTH_SIGNING_SECRET') } }); + +// server.ts — the read +const { signingKey } = service.secrets(); // SecretBox +signingKey.expose(); // the only way to the value; the box redacts everywhere else +``` + +Modules forward needs without learning the name (the auth Module above). A +secret is not a param — don't put credentials in `params`. + +## Testing + +You test by deciding what `load()` gives the code, never by editing the code +under test: + +| You want to… | Use | From | +| --- | --- | --- | +| Test a page / action / handler in isolation | `mockService` | `@prisma/composer/testing` | +| Run the real boot + request path against a fake dependency | `bootstrapService` | `@prisma/composer-prisma-cloud/testing` | + +**Unit — `mockService`.** Returns a copy of the service whose `load()` yields +your doubles (type-checked against the declared deps) and whose `config()` +yields param defaults overlaid with any overrides, in one flat object. Wiring +the module substitution is your runner's job (`vi.mock` in Vitest, +`mock.module` in bun test): + +```tsx +// page.test.tsx +import { mockService } from '@prisma/composer/testing'; +import realService from '../src/service.ts'; + +vi.mock('../src/service.ts', () => ({ + default: mockService(realService, { + auth: { verify: async () => ({ ok: true }) }, // wrong shape = compile error + }), +})); + +import Page from './page.tsx'; +expect(renderToString(await Page())).toContain('Signed in: true'); +``` + +**Integration — `bootstrapService`.** Boots the service's real built entry +in-process against a config you choose, exactly as a deployed boot would; +drive it over real HTTP. Run under `bun test`: + +```ts +import { bootstrapService } from '@prisma/composer-prisma-cloud/testing'; +import fakeAuth from '@my-app/auth/fake'; // in-memory handler, no db +import storefront from '../src/service.ts'; + +const fake = Bun.serve({ port: 0, fetch: fakeAuth }); + +const app = await bootstrapService(storefront, { + service: { port: 4310 }, + inputs: { auth: { url: fake.url.href } }, +}); + +const res = await app.fetch(new Request(app.url)); +``` + +- **`service.port` must be concrete** — the entry self-listens; no OS-assigned + port is reported back. +- **No `close()`** — run each integration-test file in its own process (bun + test does). +- **Next.js services take a third argument**, a boot thunk, because the built + entry lives in Next's standalone output — resolve it with + `standaloneServerPath` from `@prisma/composer/nextjs/control` and set + `process.env.PORT` before importing it (Next's standalone server binds + `PORT`, not the service's config). + +**The fake you pass.** A dependency's type is its contract, so any value of +that shape is a valid double: a bare object (fastest), the real client over an +in-memory handler, or a real local server (what `bootstrapService` drives). +Ship a dependency's fake from its own package as a `/fake` entry point, +outside `src/`, so the fake and the real service always share one contract. + +## Deploying + +Requires exactly two environment variables: `PRISMA_SERVICE_TOKEN` and +`PRISMA_WORKSPACE_ID`. The target environment — a **stage** — is chosen on the +command line, never in code: + +| You want to… | Run | +| --- | --- | +| Deploy to production | `prisma-composer deploy module.ts` | +| Deploy an isolated environment | `prisma-composer deploy module.ts --stage ` | +| Override the app name for one run | `prisma-composer deploy module.ts --name demo-42` | +| Tear down an isolated environment | `prisma-composer destroy module.ts --stage ` | +| Tear down production's resources | `prisma-composer destroy module.ts --production` | + +A Prisma App is one Project; a stage is a Branch of it (ADR-0023/0024) — its +own compute, its own empty database, its own configuration. Deploys are +idempotent: re-deploying a stage updates the resources inside it. A stage name +must be a valid git ref name; an invalid name is a hard error. + +Destroy always requires an explicit target — a bare `prisma-composer destroy` +is an error, and `--stage` with `--production` is too. Destroying a stage +deletes its Branch after removing its resources; the production Branch itself +is never deleted, only the resources inside it. Destroy never creates +anything: destroying a never-deployed stage fails rather than standing one up. + +```sh +turbo run build && prisma-composer deploy module.ts --stage pr-42 +``` + +## Production pitfalls + +- **Scale-to-zero closes idle database connections.** A persistent client + crashes into a 502 restart loop unless you keep the pool small and + reconnect-friendly (`new SQL({ url, max: 1, idleTimeout: 10 })` for Bun) and + log `uncaughtException`/`unhandledRejection` instead of dying. +- **Bind `0.0.0.0`**, not loopback — Compute routes external HTTP to the VM. +- **Next.js pages that call `load()` need `export const dynamic = + 'force-dynamic'`** — the runtime environment doesn't exist at build time, + and Next ignores runtime env for prerendered routes. +- **Cold starts reset service-to-service connections.** A call into a + scaled-to-zero service can get `ECONNRESET`; retry it. +- **The ingress buffers streaming responses.** An open SSE tail delivers + nothing and times out at 60s — don't build on streamed HTTP responses. + +## What Composer doesn't do yet + +Name the gap instead of inventing an API: + +- **No dev server / watch loop.** Local dev is running a server entry + directly (see each example's `dev` script); dependencies must be supplied + via the environment or a locally-run counterpart. A first-class dev loop is + on the roadmap. +- **No interactive auth.** Deploys authenticate only via a static + `PRISMA_SERVICE_TOKEN`; there is no `login` flow. +- **No in-memory contract bindings.** A dependency can't yet be wired to a + co-located handler without HTTP; use `bootstrapService` with a loopback + fake. +- **RPC over HTTP is the only contract kind.** No gRPC, WebSocket, or + streaming contracts. + +For anything else missing, check the examples and design docs in the +prisma/composer repo (`examples/`, `docs/design/10-domains/`, +`docs/design/90-decisions/`), then file an issue there rather than guessing. diff --git a/skills/testing-prisma-apps/SKILL.md b/skills/testing-prisma-apps/SKILL.md deleted file mode 100644 index 1fabc425..00000000 --- a/skills/testing-prisma-apps/SKILL.md +++ /dev/null @@ -1,110 +0,0 @@ ---- -name: testing-prisma-composers -description: >- - How to test an app built on Prisma Composer. Every dependency an app - uses arrives through one call, `service.load()`; you test by controlling what - it returns or reads, never by editing the code under test. Two tools: - `mockService` (from `@prisma/composer/testing`) for unit tests, and - `bootstrapService` (from `@prisma/composer-prisma-cloud/testing`) for integration tests. - Use when writing tests for a Prisma App, faking a service dependency, - unit-testing a page / server action / RPC handler, or integration-testing the - real request path without a deployment. Triggers on "test a prisma app", - "mockService", "bootstrapService", "fake a service dependency", - "test a page/action without deploying", "@prisma/composer/testing". ---- - -# Testing Prisma Apps - -An app's code gets its dependencies from exactly one place — `service.load()` — -and never as arguments or globals. So you test by deciding what `load()` gives -the code, and you leave the code under test unchanged. Pick the tool by how much -of the real path you want to run. - -| You want to… | Use | From | -| --- | --- | --- | -| Test a page / action / handler in isolation | `mockService` | `@prisma/composer/testing` | -| Run the real boot + request path against a fake dependency | `bootstrapService` | `@prisma/composer-prisma-cloud/testing` | - -The full model is in -[`docs/design/10-domains/testing.md`](../../docs/design/10-domains/testing.md). - -## Unit test — `mockService` - -For code you call directly. Mock the code's own service module so `load()` -returns doubles, then run the code with no server and no environment: - -```tsx -// page.test.tsx -import { mockService } from '@prisma/composer/testing'; -import realService from '../src/service.ts'; - -vi.mock('../src/service.ts', () => ({ - default: mockService(realService, { - // typed against the dependency's contract — a wrong-shaped fake won't compile - auth: { verify: async () => ({ ok: true }) }, - }), -})); - -import Page from './page.tsx'; -expect(renderToString(await Page())).toContain('Signed in: true'); -``` - -- `mockService(service, doubles)` returns a copy of the service whose `load()` - yields your `doubles` merged with the service's parameter defaults. -- The doubles are type-checked against the service's declared dependencies. -- Wiring the module substitution is your runner's job: `vi.mock` (Vitest), - `mock.module` (bun test). `mockService` only supplies the typed value. - -## Integration test — `bootstrapService` - -For the real request path: the service actually boots and serves, talking to a -stand-in you run. Point a dependency at the stand-in via the config, then drive -HTTP requests: - -```ts -// service.integration.test.ts (run under `bun test`) -import { bootstrapService } from '@prisma/composer-prisma-cloud/testing'; -import fakeAuth from '@storefront-auth/auth/fake'; // an in-memory handler, no db -import storefront from '../src/service.ts'; - -const fake = Bun.serve({ port: 0, fetch: fakeAuth }); - -const app = await bootstrapService(storefront, { - service: { port: 4310 }, - inputs: { auth: { url: fake.url.href } }, -}); - -const res = await app.fetch(new Request(app.url)); -expect(await res.text()).toContain('Signed in: true'); -``` - -- The service's own code (`server.ts`) is not modified — it boots exactly as in - production; you only choose its configuration. -- **Pass a concrete `service.port`** — the service listens on it; there's no - OS-assigned port reported back. -- **No `close()`** — run each integration-test file in its own process (bun test - does), so the started server is cleaned up when the file ends. -- **Next.js services take a third argument** — a boot thunk, because the built - entry lives in Next's standalone output directory: - - ```ts - import { standaloneEntryPath } from '@prisma/composer/nextjs/control'; - await bootstrapService(storefront, config, async () => { - await import(standaloneEntryPath(storefront.build)); - }); - ``` - -## The fake you pass - -A dependency's type *is* its contract, so any value of that shape is a valid -double, checked by the compiler. Three levels of realism: - -- **A bare object** — `{ verify: async () => ({ ok: true }) }`. Fastest. -- **The real client over an in-memory handler** — runs JSON encoding + schema - validation, no socket. -- **A real local server** — the fake served on a loopback port over real HTTP - (what `bootstrapService` drives). - -Ship a dependency's fake from its own package (a `/fake` entry point, outside -`src/` so it never reaches production) so the fake and the real service always -share one contract. From f596211cd717e37de84568b3d39d1c0585232eb3 Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 15 Jul 2026 23:06:15 +0200 Subject: [PATCH 2/3] docs: consumer-oriented README and human guides under docs/guides/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorient the root README toward people building apps on Composer: what a service and a root module look like, the real examples/store topology in place of the aspirational diagram, a guides table, an examples table, and the agent-skill install. Design docs move to a "Design & internals" section for contributors. Everything described is shipped surface — unshipped design (stream connections, shared-Postgres contract slices, topology artifact queries) is no longer presented as current. Four guides, canonical for humans (the skill is the agent-condensed mirror; skills/README.md now says so): - getting-started.md — empty directory to a deployed two-service app - building-an-app.md — contracts, databases, Modules, shared modules, params, secrets, builds - testing.md — mockService / bootstrapService / the /fake pattern - deploying.md — stages, destroy, CI, production behavior All relative links checked against the tree; ADR links use the real filenames. Signed-off-by: willbot Signed-off-by: Will Madden --- README.md | 248 ++++++++++++--------------- docs/guides/building-an-app.md | 300 +++++++++++++++++++++++++++++++++ docs/guides/deploying.md | 131 ++++++++++++++ docs/guides/getting-started.md | 244 +++++++++++++++++++++++++++ docs/guides/testing.md | 112 ++++++++++++ skills/README.md | 4 +- 6 files changed, 901 insertions(+), 138 deletions(-) create mode 100644 docs/guides/building-an-app.md create mode 100644 docs/guides/deploying.md create mode 100644 docs/guides/getting-started.md create mode 100644 docs/guides/testing.md diff --git a/README.md b/README.md index 01d45082..f4135d2c 100644 --- a/README.md +++ b/README.md @@ -2,155 +2,129 @@ **Prisma Composer** is a TypeScript framework for building and deploying applications composed of components. You build a **Prisma App** by composing -**Modules** with Prisma Composer. A component — a Module — owns its Services and -Resources and exposes typed **Inputs** and **Outputs**; you connect one Module's -Outputs to another's Inputs, and at runtime Prisma Composer injects the dependencies -that satisfy them. The whole app is itself a Module — the outermost one — and that -is what you deploy. - -From that structure the framework derives your application's dependency graph and -uses Alchemy to provision and deploy it. Deployment targets like Prisma Cloud plug -in as extension packs, so you can deploy the same application to any of them. +**Modules**: a Module owns its services and resources and exposes typed ports; +you connect one Module's outputs to another's inputs, and at runtime the +framework injects the dependencies that satisfy them. The whole app is itself +a Module — the outermost one — and that is what you deploy. + +From that structure the framework derives your application's dependency graph +and provisions it on Prisma Cloud (Compute + Prisma Postgres). There's no +infrastructure configuration to write or maintain — and the framework never +bundles or transforms your code: you build it, the deploy assembles what you +built. + +## What it looks like + +A service declares its dependencies; app code receives them from one place, +`service.load()` — never `process.env`, never a URL: + +```ts +// service.ts — the declaration: pure data +export default compute({ + name: 'storefront', + deps: { catalog: rpc(catalogContract) }, + build: nextjs({ module: import.meta.url, appDir: '..' }), +}); + +// app code — load() returns a typed client for the contract +const { catalog } = service.load(); +const { products } = await catalog.listProducts({}); +``` -There's no infrastructure configuration to write or maintain. +The root module is the app — provision the pieces, wire the typed ports: -## Example App +```ts +export default module('store', ({ provision }) => { + const catalog = provision(catalogModule); // owns its own Postgres + const orders = provision(ordersModule, { deps: { catalog: catalog.rpc } }); + provision(storefrontService, { deps: { catalog: catalog.rpc, orders: orders.rpc } }); +}); +``` -Take an online store: a **storefront** the public browses, a **sales** service -that records orders, a **delivery** service that reacts when an order is placed, -**auth**, and a **Postgres** database the services share. In a Prisma App each of -those is a component, and the edges between them are connections you declare in -code. +That's [`examples/store`](examples/store/) — four components and their edges, +deployed with one command: ```mermaid flowchart TB User((User)) - subgraph Storefront["Module: Storefront"] - SF["Next.js · Compute Service"] + subgraph Storefront["storefront"] + SF["Next.js"] end - subgraph Sales["Module: Sales & Billing"] - SA["Sales API · Compute Service"] - INV[("Invoices · object storage")] - SA --- INV + subgraph Catalog["Module: catalog"] + CA["catalog API"] + CPG[("Postgres · typed by contract.prisma")] + CA --- CPG end - subgraph Delivery["Module: Delivery"] - DE["Delivery · Compute Service"] - RD[("Updates · Redis")] - DE --- RD + subgraph Orders["Module: orders"] + OR["orders API"] + OPG[("Postgres · typed by contract.prisma")] + OR --- OPG end - subgraph Auth["Module: Auth"] - AU["Auth API · Compute Service"] + subgraph Cron["cron (shared module)"] + CR["scheduler + promotions runner"] end - PG[("Postgres — shared Resource")] - - User -->|request/response · ingress| SF - SF -->|request/response| SA - SF -->|request/response| AU - SA -->|stream · order-placed| DE - SA -->|data · sales contract| PG - DE -->|data · delivery contract| PG - AU -->|data · auth contract| PG + + User -->|HTTP| SF + SF -->|rpc · catalogContract| CA + SF -->|rpc · ordersContract| OR + OR -->|rpc · catalogContract| CA + CR -->|rpc · catalogContract| CA +``` + +Each box is a **Module**: a boundary that owns some code and data and is +reachable only through typed ports. catalog and orders each own their own +Postgres internally — the root never sees them; the only edges are the +exposed, contract-typed RPC ports. Because nothing reaches inside a boundary, +every dependency in the app is an explicit, compiler-checked edge. + +## Getting started + +```sh +pnpm add @prisma/composer @prisma/composer-prisma-cloud arktype +``` + +Follow **[Getting started](docs/guides/getting-started.md)** — empty directory +to a deployed two-service app. + +| Guide | Covers | +| --- | --- | +| [Getting started](docs/guides/getting-started.md) | Your first app: contract → service → root module → deploy | +| [Building an app](docs/guides/building-an-app.md) | Contracts, databases (plain + Prisma Next-typed), reusable Modules, cron/storage/streams, config params, secrets, builds | +| [Testing](docs/guides/testing.md) | Unit tests with `mockService`, integration tests with `bootstrapService` | +| [Deploying and operating](docs/guides/deploying.md) | Stages, destroy semantics, CI, production behavior | + +## Examples + +Complete, deployable apps under [`examples/`](examples/): + +| Example | Demonstrates | +| --- | --- | +| [pn-widgets](examples/pn-widgets/) | The minimal app: one service + one Prisma Next-typed Postgres | +| [storefront-auth](examples/storefront-auth/) | Next.js consumer + API producer, a reusable Module owning its database, secrets | +| [store](examples/store/) | Four modules, typed databases with migrations, the shared cron module | +| [cron](examples/cron/) | Scheduled jobs: `defineSchedule` + `serveSchedule` + the cron module | +| [storage](examples/storage/) | The S3-backed blob store module | +| [streams](examples/streams/) | Durable append-only event streams over storage | + +## For agents + +A condensed version of the guides ships as an installable agent skill: + +```sh +npx skills add prisma/composer --skill prisma-composer ``` -## Modules - -Each box above is a **Module**: a bounded context that owns some code and data and -is reachable only through a typed boundary. Because nothing reaches inside except -through that boundary, every dependency between Modules is an explicit edge in the -graph. A Module is scale-invariant — it composes recursively, and the whole -application is just the outermost Module — so the root needs no special construct. - -A Module wraps two kinds of thing. **Services** are the units that run code — an -HTTP API, the Next.js storefront, a background worker. **Resources** are the -stateful things it depends on: Postgres is first-class, and anything else (object -storage, a cache, a queue) comes in as an Alchemy resource surfaced as a typed -capability. - -You can see this in the diagram: every Module has a Compute Service, Sales also -owns a private invoices store, and Delivery a Redis instance for its live updates. -Those private Resources sit inside the boundary, so they never become edges between -Modules — only the shared Postgres, used by several Modules at once, does. - -> A Module is a bounded context in the ports-and-adapters (hexagonal) sense — it -> reaches the world only through typed ports, which is exactly what its Inputs and -> Outputs are. The name is the one the neighbouring ecosystems already use for a -> composable, boundary-owning unit: a Nest or Angular module, a Terraform module. - -## Connections - -A Module declares **Inputs** for what it needs and **Outputs** for what it offers; -a connection joins one Module's Output to another's Input. There are two kinds. - -**Communication** is one Module calling another — either **request/response** (the -storefront calls the sales API) or a **stream** (sales emits *order-placed*, and -delivery consumes it). - -**Data** is a Module reading and writing a Postgres Resource. The data Input carries -a **contract** — a Prisma Next description of exactly the tables and columns the -Module may touch — and a Postgres satisfies the connection only if it meets that -contract. In the store, one Postgres is shared by sales, delivery, and auth, but -each owns a separate slice named by its own contract. The instance is shared; the -data boundaries are not, and each one stays a visible edge rather than a hidden -reach into another Module's tables. - -## Topology - -The wired-up graph of Modules, Resources, and connections is the **topology**. -The framework infers it from your TypeScript and emits it as a standalone artifact — -one you or an agent can query from the CLI without deploying anything. - -## From code to a running app - -Prisma Composer spans three planes and **lowers** your model down through them: - -- **Authoring** — what you write: Modules, Services, Resources, connections. -- **Provisioning** — the framework turns the topology into an Alchemy resource - graph, and Alchemy's engine provisions it, run from your machine or your CD - pipeline. -- **Hosting** — what actually runs. On Prisma Cloud, each Service becomes Prisma - Compute, the shared Postgres becomes one managed database, and a stream becomes - a durable stream. - -Prisma Cloud is one hosting target, shipped as an extension pack — the core knows -nothing about it, and a different target plugs in the same way. Because the target -supplies each Resource's implementation, that implementation is substitutable: the -same Module runs against managed Postgres in the cloud and a local one in -development, with no change to your code. - -## Decisions - -- **Prisma Composer owns composition and borrows everything beneath it.** The Module - model and the topology are its own; provisioning is Alchemy, data contracts are - Prisma Next, hosting is Prisma Cloud. It rebuilds none of them. -- **Thin core, fat targets.** The core is the Module / Input / Output model and the - lowering machinery, nothing more. Everything specific to a deployment target - lives in a swappable pack, and the core never branches on where you deploy. -- **Your code is the source of truth.** The topology is read out of your - TypeScript and type-checked, so it can't drift from a config you maintain on the - side. -- **No globals.** Application code never reads `process.env` or looks a service up - by name. Every resource a Module uses arrives as an injected, typed dependency. -- **Agent-first and realtime-first.** The topology is explicit, machine-readable, - and queryable, so agents and people reason about it directly. Streaming and - async are first-class from the start, not bolted onto request/response later. - -## Status - -The design lives in `docs/design/`; this page is the short version. Core, -extension packs, and the `prisma-composer` deploy CLI are implemented under -`packages/`, and the example apps under `examples/` deploy with -`prisma-composer deploy` / `prisma-composer destroy` (see -[`docs/design/10-domains/deploy-cli.md`](docs/design/10-domains/deploy-cli.md)). - -- **Purpose and goals** — [`docs/design/00-purpose/`](docs/design/00-purpose/) -- **Principles** — [guiding](docs/design/01-principles/guiding-principles.md), - [architectural](docs/design/01-principles/architectural-principles.md) -- **Domain model** — [glossary](docs/design/03-domain-model/glossary.md), - [domain map](docs/design/03-domain-model/domain-map.md), - [layering](docs/design/03-domain-model/layering.md) -- **Worked example** — [`docs/design/02-example-app/`](docs/design/02-example-app/) -- **Inspirations** — - [`docs/design/04-inspirations/`](docs/design/04-inspirations/) (Alchemy, Convex, - and others) +See [`skills/`](skills/). + +## Design & internals + +For contributors — the design record, not required reading for building apps: + +- **Purpose and principles** — [`docs/design/00-purpose/`](docs/design/00-purpose/), + [`docs/design/01-principles/`](docs/design/01-principles/) +- **Domain deep dives** — [`docs/design/10-domains/`](docs/design/10-domains/) + (core model, contracts, module composition, config, deploy CLI, testing) +- **Decisions** — [`docs/design/90-decisions/`](docs/design/90-decisions/) (the ADR index) - **Reading order** — [`docs/design/README.md`](docs/design/README.md) +- **Platform footguns** — [`gotchas.md`](gotchas.md) diff --git a/docs/guides/building-an-app.md b/docs/guides/building-an-app.md new file mode 100644 index 00000000..a967463c --- /dev/null +++ b/docs/guides/building-an-app.md @@ -0,0 +1,300 @@ +# Building an app + +The authoring guide: everything a service can declare, how Modules compose, +and the building blocks that ship with the framework. It assumes you've been +through [Getting started](getting-started.md). + +## The model in one paragraph + +A Prisma App is a tree of **Modules**. The leaves are **services** +(`compute()` — units that run your code) and **resources** (`postgres()` — +stateful things services depend on). Every dependency is declared on the +service and wired by a parent module; app code receives dependencies from +exactly one place, `service.load()`, and never reads `process.env` or looks a +service up by name. The framework never bundles or transforms your code — you +build it, the deploy assembles the built output +([ADR-0005](../design/90-decisions/ADR-0005-users-build-the-framework-assembles.md)). + +A service declaration: + +```ts +compute({ + name: 'auth', // the node's name; also its default provision id + deps: { db: postgres() }, // typed dependency slots — read via load() + params: { region: string() }, // config values — read via config() + secrets: { key: secret() }, // secret needs — read via secrets() + build: node({ module: import.meta.url, entry: '../dist/server.mjs' }), + expose: { rpc: authContract },// ports other services can depend on +}); +``` + +`deps`, `params`, and `secrets` are three separate namespaces with three +separate accessors — a dependency and a config value never masquerade as each +other ([ADR-0021](../design/90-decisions/ADR-0021-params-are-read-through-config-not-load.md)). + +## Contracts + +A **contract** types the edge between two services. The producer exposes and +serves it; a consumer depends on it and receives a typed client. The contract +lives with the service that owns it: + +```ts +import { contract, rpc } from '@prisma/composer/rpc'; +import { type } from 'arktype'; + +export const authContract = contract({ + verify: rpc({ input: type({ token: 'string' }), output: type({ ok: 'boolean' }) }), +}); +``` + +Producer side — `serve()` turns the service's `expose` into a fetch handler. +The handler map is keyed by the expose port's name, exhaustive at compile +time, and each handler receives the validated input (plus the service's own +loaded deps as a second argument): + +```ts +const handler = serve(service, { + rpc: { + verify: async ({ token }) => ({ ok: await check(token) }), + }, +}); +``` + +Consumer side — declare `deps: { auth: rpc(authContract) }` and +`service.load()` returns `{ auth }` as a typed client: `await +auth.verify({ token })`. Input and output are validated at the boundary at +runtime, against the same schemas that type both ends. + +RPC over HTTP is the only contract kind today — no gRPC, WebSocket, or +streaming contracts. + +## Databases + +Two kinds of Postgres dependency, by how much the framework does for you: + +**`postgres()`** — the binding is `{ url }` and the app owns its client +([ADR-0015](../design/90-decisions/ADR-0015-dependencies-resolve-to-bindings-clients-are-app-side.md)). +Build whatever client you like in the server entry: + +```ts +import { SQL } from 'bun'; +const { db } = service.load(); +const sql = new SQL({ url: db.url, max: 1, idleTimeout: 10 }); +``` + +(Those pool settings matter in production — see +[Deploying and operating](deploying.md#production-behavior).) + +**`pnPostgres(...)`** — a [Prisma Next](https://github.com/prisma/prisma-next)-typed +database ([ADR-0022](../design/90-decisions/ADR-0022-data-deps-carry-a-prisma-next-contract.md)): +`load()` returns a typed client constructed from your data contract, so +queries are compile-time checked against the schema — +`db.orm.public.Product.where({ id }).first()`, no SQL strings, no row +mapping. Three pieces: + +- `contract.prisma` — the schema. `prisma-next contract emit` turns it into + `contract.json` + `contract.d.ts`, which you wrap once: + + ```ts + // src/data.ts — the ONE value both ends reference + import { pnContract } from '@prisma/composer-prisma-cloud/prisma-next'; + import type { Contract } from '../contract.d.ts'; + import contractJson from '../contract.json' with { type: 'json' }; + + export const catalogData = pnContract(contractJson); + ``` + +- The dependency end: `deps: { db: pnPostgres(catalogData) }`. +- The resource end (in the module that owns the database): also names the + `prisma-next.config.ts` path, which the deploy's migration step loads to + find `migrations/`: + + ```ts + const db = provision(pnPostgres({ name: 'database', contract: catalogData, config })); + ``` + +Migrations are applied at deploy, before the service starts — no +`CREATE TABLE IF NOT EXISTS` in app code. The deploy refuses to wire a +service against a database with a different contract. +[`examples/store/modules/catalog`](../../examples/store/modules/catalog/) is +the complete pattern; [`examples/pn-widgets`](../../examples/pn-widgets/) is +the minimal one. + +## Modules + +A Module is the unit of composition and reuse: it owns its internals (its +database, its services) and is reachable only through its typed boundary. + +**A closed root** — no boundary, only provisions. This is the app: + +```ts +export default module('my-app', ({ provision }) => { + const auth = provision(authModule); + provision(storefrontService, { deps: { auth: auth.rpc } }); +}); +``` + +**A reusable Module** — declares a boundary (what it needs and what it +offers) in the second argument, wires its internals in the builder, and +returns its exposed ports: + +```ts +import { module, secret } from '@prisma/composer'; +import { postgres } from '@prisma/composer-prisma-cloud'; + +export default module( + 'auth', + { secrets: { signingKey: secret() }, expose: { rpc: authContract } }, + ({ secrets, provision }) => { + const db = provision(postgres({ name: 'database' })); + const service = provision(authService, { + id: 'service', + deps: { db }, + secrets: { signingKey: secrets.signingKey }, + }); + return { rpc: service.rpc }; + }, +); +``` + +A consumer provisions this Module and wires `auth.rpc` — it never sees the +database. A Module can also declare boundary `deps`: inputs the parent +supplies, wired exactly like a service's. + +`provision(node, opts?)` accepts: + +- `id` — the provision's name in the graph; defaults to the node's own name. + Give an explicit id when the default would stutter (`auth.auth`). +- `deps` — wire each declared dependency slot to a provisioned ref or an + exposed port. +- `secrets` — bind each secret need (see § Secrets). + +One naming rule bites: the platform rejects provision names shorter than +three characters, so name a database `'database'`, not `'db'`. (The wiring +key — the service's own input name — can still be `db`.) + +## Shared modules + +First-party reusable Modules ship under `@prisma/composer-prisma-cloud`: + +| Import | What it provisions | Exposes | +| --- | --- | --- | +| `cron` from `/cron` | An always-on scheduler firing your schedule at your runner service | nothing | +| `storage` from `/storage` | An S3-backed blob store (own Postgres + minted credentials) | `store` | +| `streams` from `/streams` | Durable append-only event streams, backed by a `store` | `streams` | + +Cron end to end. The schedule is the one source of truth for job ids and +intervals; `serveSchedule` is exhaustive over its ids at compile time, so an +unhandled job doesn't compile: + +```ts +// service.ts — the runner declares what the jobs need +import { defineSchedule, triggerContract } from '@prisma/composer-prisma-cloud/cron'; + +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 }, +}); + +// server.ts — map each job id to work +import { serveSchedule } from '@prisma/composer-prisma-cloud/cron'; +const handler = serveSchedule(service, schedule, { + rotateSpecial: (deps) => deps.catalog.rotateSpecial({}), +}); + +// module.ts — the cron module's boundary deps mirror the runner's own, +// so the root wires them like any other edge +provision(cron({ schedule, runner: promotionsService }), { + deps: { catalog: catalog.rpc }, +}); +``` + +Storage and streams compose the same way — +[`examples/storage`](../../examples/storage/) and +[`examples/streams`](../../examples/streams/) show the wiring, including the +streams module's secret binding. + +## Config params + +Params are caller-owned schemas on the declaration +([ADR-0018](../design/90-decisions/ADR-0018-config-params-carry-a-caller-owned-schema.md)): +the schema types the param and validates the value at boot. `string()` and +`number()` cover the scalars; `param(schema)` wraps any Standard Schema: + +```ts +import { param, string } from '@prisma/composer'; +import { type } from 'arktype'; + +compute({ + name: 'scheduler', + params: { + jobs: param(type({ jobId: 'string', every: 'string' }).array()), + region: string({ optional: true }), + }, + // ... +}); +``` + +Read them with `service.config()`. Facets are `default` and `optional` — they +describe how a value is handled, not what it is. Every service has a reserved +`port` param (default 3000); declaring your own `port` is an authoring error. + +## Secrets + +Secret values never travel through framework config +([ADR-0029](../design/90-decisions/ADR-0029-secrets-are-a-forwardable-slot.md)). +A service declares a nameless **need**; the root binds it to a platform +env-var name; the value stays only in that platform variable: + +```ts +// the need (service.ts) +compute({ /* ... */ secrets: { signingKey: secret() } }); + +// the binding (module.ts root) +provision(authModule, { secrets: { signingKey: envSecret('AUTH_SIGNING_SECRET') } }); + +// the read (server.ts) +const { signingKey } = service.secrets(); // SecretBox +signingKey.expose(); // the only way to the value +``` + +The `SecretBox` redacts everywhere except `.expose()` — logging it, rendering +it, or JSON-serializing it never leaks the value. Modules forward needs +without ever learning the platform name (the auth Module above). A secret is +not a param; don't put credentials in `params`. + +## Builds + +The framework assembles only what you built. For plain server processes, the +shipped tsdown preset makes each entry self-contained (everything inlined +except runtime built-ins): + +```ts +import { prismaTsDownConfig } from '@prisma/composer/tsdown'; +export default prismaTsDownConfig({ entry: { server: 'src/server.ts' }, outDir: 'dist' }); +``` + +Two services in one package = two separate builds into separate `outDir`s. + +For Next.js, `next build` with `output: 'standalone'` is the whole build; +declare it with `nextjs({ module: import.meta.url, appDir: '..' })` from +`@prisma/composer/nextjs`, and add `nextjsBuild()` from +`@prisma/composer/nextjs/control` to the deploy config's `extensions`. One +Next-specific rule: a page that calls `service.load()` needs +`export const dynamic = 'force-dynamic'` — the runtime environment doesn't +exist at build time. + +## Further reading + +- [`docs/design/10-domains/core-model.md`](../design/10-domains/core-model.md) + — the complete type-level design. +- [`docs/design/10-domains/connection-contracts.md`](../design/10-domains/connection-contracts.md) + — contracts in depth. +- [`docs/design/10-domains/module-composition.md`](../design/10-domains/module-composition.md) + — boundaries, forwarding, nesting. +- [`docs/design/10-domains/config-params.md`](../design/10-domains/config-params.md) + — the config round trip, end to end. diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md new file mode 100644 index 00000000..f452d67b --- /dev/null +++ b/docs/guides/deploying.md @@ -0,0 +1,131 @@ +# Deploying and operating + +`prisma-composer` deploys the app whose root module is your entry file's +default export. Two commands, `deploy` and `destroy`; the target environment — +called a **stage** — is chosen on the command line, never in your code. + +| You want to… | Run | +| --- | --- | +| Deploy to production | `prisma-composer deploy module.ts` | +| Deploy an isolated environment | `prisma-composer deploy module.ts --stage ` | +| Override the app name for one run | `prisma-composer deploy module.ts --name demo-42` | +| Tear down an isolated environment | `prisma-composer destroy module.ts --stage ` | +| Tear down production's resources | `prisma-composer destroy module.ts --production` | + +## Credentials + +A deploy needs exactly two environment variables: `PRISMA_SERVICE_TOKEN` and +`PRISMA_WORKSPACE_ID`. Nothing else — the CLI resolves the app's Project (by +the root module's name) and, for a named stage, the stage's Branch, creating +either if it doesn't exist yet. A fresh checkout with just those two variables +set deploys successfully. + +Keep the values out of the repo — an `.env` you source at deploy time, or CI +secrets. There is no interactive login; the static token is the only +authentication. + +## Build first + +`prisma-composer deploy` does not build for you: + +```sh +turbo run build && prisma-composer deploy module.ts +``` + +The deploy assembles what your build produced (the `entry` each service's +build adapter names) and provisions it. Deploy state lives in a +workspace-hosted ledger (the `prismaState()` line in +`prisma-composer.config.ts`), shared by every deployer of the app — your +machine and CI see the same state, and concurrent deploys of the same stage +are locked against each other. + +## Stages + +Same code, many environments. Deploying with no `--stage` targets +**production**, at the Project level. `--stage ` targets a **named +stage** — a Branch of that same Project, with its own compute, its own empty +database, and its own configuration +([ADR-0023](../design/90-decisions/ADR-0023-a-prisma-app-is-one-project-a-stage-is-a-branch.md), +[ADR-0024](../design/90-decisions/ADR-0024-a-stage-is-a-deploy-time-environment-resolved-to-project-and-branch.md)). +Every environment repeats the app's full topology; only the data and config +differ. + +```sh +prisma-composer deploy module.ts # production +prisma-composer deploy module.ts --stage staging # an isolated "staging" environment +prisma-composer deploy module.ts --stage pr-42 # one isolated environment per PR +``` + +Re-deploying the same stage is idempotent: it finds the existing Project and +Branch and updates the resources inside them. A stage name must be a valid +git ref name (checked with `git check-ref-format`); an invalid name is a hard +error, never silently normalized. + +Each deployed service becomes a Compute service in the Project; its public +URL is its service endpoint domain, shown in the +[Prisma Console](https://console.prisma.io). + +## Destroy requires an explicit target + +A bare `prisma-composer destroy` is an error — name what you're tearing down: + +```sh +prisma-composer destroy module.ts --stage staging # removes staging's resources, then its Branch +prisma-composer destroy module.ts --production # removes production's resources +``` + +`--stage` and `--production` together is also an error — pick one. Destroying +a named stage deletes its Branch after removing its resources; the production +Branch itself is never deleted, only the resources inside it. Destroy never +creates anything: destroying a stage that was never deployed fails with +"nothing deployed" rather than standing one up first. + +## CI + +The commands are the same in CI — set the two variables as CI secrets, build, +deploy. The per-PR pattern: + +```sh +prisma-composer deploy module.ts --stage "pr-$PR_NUMBER" # on push +prisma-composer destroy module.ts --stage "pr-$PR_NUMBER" # on close +``` + +Secrets your app declares (see +[Building an app § Secrets](building-an-app.md#secrets)) are bound to +platform env-var names by the root module; the deploy provisions their values +from the deploying shell's environment, so CI must also export those +variables (e.g. `AUTH_SIGNING_SECRET`). + +## Production behavior + +Things every deployed app runs into: + +- **Compute scales to zero, and idle database connections are closed.** A + persistent client crashes into a 502 restart loop unless you keep the pool + reconnect-friendly and refuse to die on async errors: + + ```ts + const sql = new SQL({ url: db.url, max: 1, idleTimeout: 10 }); + process.on('uncaughtException', (err) => console.error('uncaughtException', err)); + process.on('unhandledRejection', (err) => console.error('unhandledRejection', err)); + ``` + +- **Bind `0.0.0.0`**, not loopback — Compute routes external HTTP to the VM, + so a loopback-only listener is unreachable. +- **Cold starts reset service-to-service connections.** A call into a + scaled-to-zero service can get `ECONNRESET` while it wakes; retry it. +- **The ingress buffers streaming responses.** An open SSE tail delivers + nothing and times out at 60s — don't build on streamed HTTP responses. +- **Next.js pages that call `service.load()` need + `export const dynamic = 'force-dynamic'`** — the runtime environment + doesn't exist at build time, and Next ignores runtime env for prerendered + routes. + +The full catalogue of platform footguns, with diagnoses, is +[`gotchas.md`](../../gotchas.md) at the repo root. + +## Full model + +[`docs/design/10-domains/deploy-cli.md`](../design/10-domains/deploy-cli.md) +— the pipeline, stages and containers, the destroy contract, the error +surface. diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md new file mode 100644 index 00000000..ac982767 --- /dev/null +++ b/docs/guides/getting-started.md @@ -0,0 +1,244 @@ +# Getting started + +From an empty directory to a deployed two-service Prisma App. You'll build a +`quotes` service that owns an RPC contract, a public `gateway` that calls it, +compose them in a root module, and deploy. + +Prerequisites: [Bun](https://bun.sh) (the server runtime used throughout), +pnpm or npm, and a Prisma Cloud workspace (for the deploy at the end). + +## 1. Project setup + +```sh +mkdir my-app && cd my-app && pnpm init +pnpm add @prisma/composer @prisma/composer-prisma-cloud arktype +pnpm add -D tsdown typescript @types/bun +``` + +```jsonc +// tsconfig.json +{ + "compilerOptions": { + "target": "ES2022", + "module": "Preserve", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "types": ["bun"] + }, + "include": ["module.ts", "src"] +} +``` + +The layout you're about to create: + +``` +my-app/ +├── module.ts # the root module — the app itself +├── prisma-composer.config.ts # deploy config (read only by the CLI) +├── tsdown.config.ts # your build +└── src/ + ├── quotes/ + │ ├── contract.ts # the service's public RPC contract + │ ├── service.ts # the declaration: deps + build + expose + │ └── server.ts # the entry your build produces + └── gateway/ + ├── service.ts + └── server.ts +``` + +## 2. The quotes service + +**The contract** — the typed edge other services depend on. It lives with the +service that owns it. Any Standard Schema validator works; arktype is what the +examples use: + +```ts +// src/quotes/contract.ts +import { contract, rpc } from '@prisma/composer/rpc'; +import { type } from 'arktype'; + +export const quotesContract = contract({ + random: rpc({ input: type({}), output: type({ quote: 'string' }) }), +}); +``` + +**The declaration** — pure data: a name, dependencies (none yet), how the +service is built, and what it exposes: + +```ts +// src/quotes/service.ts +import node from '@prisma/composer/node'; +import { compute } from '@prisma/composer-prisma-cloud'; +import { quotesContract } from './contract.ts'; + +export default compute({ + name: 'quotes', + deps: {}, + build: node({ module: import.meta.url, entry: '../../dist/quotes/server.mjs' }), + expose: { rpc: quotesContract }, +}); +``` + +**The server entry** — what your build produces and the platform boots. +`serve()` generates the HTTP handler from the contract; the handler map is +checked against it at compile time, so a missing or wrong-shaped handler +doesn't compile: + +```ts +// src/quotes/server.ts +import { serve } from '@prisma/composer/rpc'; +import service from './service.ts'; + +const { port } = service.config(); + +const QUOTES = [ + 'Simplicity is prerequisite for reliability.', + 'Make it work, make it right, make it fast.', +]; + +const handler = serve(service, { + rpc: { + random: async () => ({ quote: QUOTES[Math.floor(Math.random() * QUOTES.length)]! }), + }, +}); +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 }); +``` + +## 3. The gateway service + +The gateway declares a dependency on the quotes contract and gets a typed +client back from `load()` — no URL, no fetch wrapper, no client setup: + +```ts +// src/gateway/service.ts +import node from '@prisma/composer/node'; +import { rpc } from '@prisma/composer/rpc'; +import { compute } from '@prisma/composer-prisma-cloud'; +import { quotesContract } from '../quotes/contract.ts'; + +export default compute({ + name: 'gateway', + deps: { quotes: rpc(quotesContract) }, + build: node({ module: import.meta.url, entry: '../../dist/gateway/server.mjs' }), +}); +``` + +```ts +// src/gateway/server.ts +import service from './service.ts'; + +const { quotes } = service.load(); +const { port } = service.config(); + +Bun.serve({ + port, + hostname: '0.0.0.0', + fetch: async () => { + const { quote } = await quotes.random({}); + return new Response(quote); + }, +}); +``` + +Nothing exposes a contract here, so there's no `serve()` — the gateway is an +ordinary HTTP server that happens to receive a typed client. + +## 4. Compose the app + +The root module provisions both services and wires the exposed port into the +dependency slot. Its default export is the app: + +```ts +// module.ts +import { module } from '@prisma/composer'; +import gatewayService from './src/gateway/service.ts'; +import quotesService from './src/quotes/service.ts'; + +export default module('my-app', ({ provision }) => { + const quotes = provision(quotesService); + provision(gatewayService, { deps: { quotes: quotes.rpc } }); +}); +``` + +The deploy config sits next to it. It is read only by `prisma-composer +deploy`/`destroy` — app code never imports it: + +```ts +// prisma-composer.config.ts +import { defineConfig } from '@prisma/composer/config'; +import { nodeBuild } from '@prisma/composer/node/control'; +import { prismaCloud, prismaState } from '@prisma/composer-prisma-cloud/control'; + +export default defineConfig({ + extensions: [prismaCloud(), nodeBuild()], + state: () => prismaState(), +}); +``` + +## 5. Build + +You own the build; the framework only assembles what you built. The shipped +tsdown preset makes each entry self-contained. Two services means two separate +builds into separate output directories — not one multi-entry build, which +would split shared code (the contract) into a chunk neither output contains: + +```ts +// tsdown.config.ts +import { prismaTsDownConfig } from '@prisma/composer/tsdown'; + +export default [ + prismaTsDownConfig({ entry: { server: 'src/quotes/server.ts' }, outDir: 'dist/quotes' }), + prismaTsDownConfig({ entry: { server: 'src/gateway/server.ts' }, outDir: 'dist/gateway' }), +]; +``` + +```sh +pnpm tsdown +``` + +## 6. Deploy + +A deploy needs exactly two environment variables — a service token and the +workspace id from your Prisma Cloud workspace: + +```sh +export PRISMA_SERVICE_TOKEN=... +export PRISMA_WORKSPACE_ID=... + +pnpm exec prisma-composer deploy module.ts +``` + +The CLI resolves (or creates) the app's Project, provisions both services on +Prisma Compute, wires the gateway's `quotes` dependency to the deployed quotes +service, and starts them. Each service becomes a Compute service in the +Project; its public URL is its service endpoint domain, shown in the +[Prisma Console](https://console.prisma.io). Open the gateway's URL and you +get a quote — served over one typed RPC hop. + +Re-deploying is idempotent: it updates the resources inside the same Project. +To stand up an isolated copy (same topology, separate everything), deploy a +**stage**, and tear it down when done: + +```sh +pnpm exec prisma-composer deploy module.ts --stage demo +pnpm exec prisma-composer destroy module.ts --stage demo +``` + +## Where to go next + +- [Building an app](building-an-app.md) — databases, reusable Modules, the + shared cron/storage/streams modules, config params, secrets. +- [Testing](testing.md) — unit tests with `mockService`, integration tests + with `bootstrapService`. +- [Deploying and operating](deploying.md) — stages, destroy semantics, CI, + production behavior. +- [`examples/`](../../examples/) — complete apps, from a single service with a + typed database ([pn-widgets](../../examples/pn-widgets/)) to a four-module + store with cron ([store](../../examples/store/)). diff --git a/docs/guides/testing.md b/docs/guides/testing.md new file mode 100644 index 00000000..809b7646 --- /dev/null +++ b/docs/guides/testing.md @@ -0,0 +1,112 @@ +# Testing + +An app's code gets its dependencies from exactly one place — `service.load()` +— and never as arguments or globals. So you test by deciding what `load()` +gives the code, and you leave the code under test unchanged. Pick the tool by +how much of the real path you want to run: + +| You want to… | Use | From | +| --- | --- | --- | +| Test a page / action / handler in isolation | `mockService` | `@prisma/composer/testing` | +| Run the real boot + request path against a fake dependency | `bootstrapService` | `@prisma/composer-prisma-cloud/testing` | + +The full model is in +[`docs/design/10-domains/testing.md`](../design/10-domains/testing.md). + +## Unit tests — `mockService` + +For code you call directly. `mockService(service, overrides)` returns a copy +of the service whose `load()` yields your doubles and whose `config()` yields +the param defaults overlaid with any overrides — one flat object; dependency +keys route to `load()`, param keys to `config()`. The doubles are type-checked +against the service's declared dependencies, so a wrong-shaped fake doesn't +compile. + +Wiring the module substitution is your runner's job — `vi.mock` in Vitest, +`mock.module` in bun test; `mockService` only supplies the typed value: + +```tsx +// page.test.tsx +import { mockService } from '@prisma/composer/testing'; +import realService from '../src/service.ts'; + +vi.mock('../src/service.ts', () => ({ + default: mockService(realService, { + auth: { verify: async () => ({ ok: true }) }, + }), +})); + +import Page from './page.tsx'; +expect(renderToString(await Page())).toContain('Signed in: true'); +``` + +No Postgres, no server, no environment — the page renders against the fake. + +## Integration tests — `bootstrapService` + +For the real request path: the service's actual built entry boots in-process, +exactly as a deployed boot would, against a config you choose. Point a +dependency at a stand-in you run, then drive real HTTP. Run under `bun test`: + +```ts +// service.integration.test.ts +import { bootstrapService } from '@prisma/composer-prisma-cloud/testing'; +import fakeAuth from '@my-app/auth/fake'; // an in-memory handler, no db +import storefront from '../src/service.ts'; + +const fake = Bun.serve({ port: 0, fetch: fakeAuth }); + +const app = await bootstrapService(storefront, { + service: { port: 4310 }, + inputs: { auth: { url: fake.url.href } }, +}); + +const res = await app.fetch(new Request(app.url)); +expect(await res.text()).toContain('Signed in: true'); +``` + +The rules: + +- **The service's own code is not modified** — it boots exactly as in + production; you only choose its configuration. +- **`service.port` must be concrete** — the entry self-listens; no + OS-assigned port is reported back. +- **No `close()`** — run each integration-test file in its own process (bun + test does), so the started server is cleaned up when the file ends. +- **Build first** — `bootstrapService` boots the *built* entry, so the test + task must depend on the build (turbo's `test` → `build` dependency handles + this in the examples). + +**Next.js services take a third argument** — a boot thunk — because the built +entry lives in Next's standalone output. Resolve it with +`standaloneServerPath` from `@prisma/composer/nextjs/control`, and hand the +port to Next explicitly (its standalone server binds `process.env.PORT`, not +the service's config): + +```ts +import { standaloneServerPath } from '@prisma/composer/nextjs/control'; + +await bootstrapService(storefront, config, async () => { + process.env.PORT = String(PORT); + await import(pathToFileURL(standaloneServerPath(storefront.build)).href); +}); +``` + +The complete pattern is +[`examples/storefront-auth/modules/storefront/app/page.integration.test.ts`](../../examples/storefront-auth/modules/storefront/app/page.integration.test.ts). + +## The fake you pass + +A dependency's type *is* its contract, so any value of that shape is a valid +double, checked by the compiler. Three levels of realism: + +- **A bare object** — `{ verify: async () => ({ ok: true }) }`. Fastest. +- **The real client over an in-memory handler** — runs JSON encoding + schema + validation, no socket. +- **A real local server** — the fake served on a loopback port over real HTTP + (what `bootstrapService` drives). + +Ship a dependency's fake from its own package as a `/fake` entry point, +outside `src/` so it never reaches production. The fake and the real service +then always share one contract — when the contract changes, both stop +compiling together. diff --git a/skills/README.md b/skills/README.md index 6b6c2410..f2c8ac3a 100644 --- a/skills/README.md +++ b/skills/README.md @@ -26,7 +26,9 @@ surface tracks this repo's packages. ## Authoring rules -For anyone editing the skill: +The skill is the agent-condensed mirror of the human guides in +[`docs/guides/`](../docs/guides/) — the guides are canonical for humans; a +surface change lands in both. For anyone editing the skill: - **Verify every claim while drafting, not in a final pass.** Every import must resolve against a `packages/9-public/*` export map, and every CLI From 9d3c7b52404761b7525259405c730e78eab6c82e Mon Sep 17 00:00:00 2001 From: willbot Date: Wed, 15 Jul 2026 23:16:26 +0200 Subject: [PATCH 3/3] =?UTF-8?q?docs(guides):=20rewrite=20for=20the=20reade?= =?UTF-8?q?r=20=E2=80=94=20a=20Prisma=20engineer=20new=20to=20Composer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fresh-eyes rewrite of the README and all four guides, targeting a teammate who does not know Composer but wants to build or port an app onto Prisma Cloud, probably with Prisma Next. - README: opens with what the tool does and when it earns its keep (multi-piece apps), instead of category language; names the porting path. - getting-started: adds "Run it locally" (COMPOSER_* env vars — verified by actually running the cron example services locally and curling them) and a "Porting an existing app" section (Node/Bun service, Next.js app, multi-service); explains why Bun; says where the two deploy credentials come from. - building-an-app: reader-oriented opening (was a fragment); the Prisma Next database section now spells out the schema-change workflow (contract.prisma -> contract emit -> migration plan -> deploy applies); design-doc phrasing translated into reader benefit; ADR citations moved out of sentence flow into a closing "Digging deeper" list. - testing: says when to pick which seam; example is a complete runnable Vitest test with its imports. - deploying: explains deploy state and locking in plain terms; says where credentials come from; pitfalls phrased as what-you-hit -> what-to-do. All relative links and intra-guide anchors script-checked; vocabulary lint clean. Signed-off-by: willbot Signed-off-by: Will Madden --- README.md | 62 +++--- docs/guides/building-an-app.md | 353 +++++++++++++++++++-------------- docs/guides/deploying.md | 146 +++++++------- docs/guides/getting-started.md | 194 +++++++++++++----- docs/guides/testing.md | 108 +++++----- 5 files changed, 511 insertions(+), 352 deletions(-) diff --git a/README.md b/README.md index f4135d2c..48b4ae92 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,41 @@ # Prisma Composer -**Prisma Composer** is a TypeScript framework for building and deploying -applications composed of components. You build a **Prisma App** by composing -**Modules**: a Module owns its services and resources and exposes typed ports; -you connect one Module's outputs to another's inputs, and at runtime the -framework injects the dependencies that satisfy them. The whole app is itself -a Module — the outermost one — and that is what you deploy. - -From that structure the framework derives your application's dependency graph -and provisions it on Prisma Cloud (Compute + Prisma Postgres). There's no -infrastructure configuration to write or maintain — and the framework never -bundles or transforms your code: you build it, the deploy assembles what you -built. +**Prisma Composer** deploys multi-service TypeScript apps to Prisma Cloud +from a description you write in TypeScript. You declare each service and what +it depends on — other services, databases, schedules, secrets — compose them +into a **Prisma App**, and `prisma-composer deploy` provisions all of it: +Compute services, Prisma Postgres databases, the wiring between them. There +is no infrastructure configuration to write or maintain. + +It earns its keep when an app is more than one piece. A lone server is easy +to deploy anywhere; the work starts when the storefront needs the catalog's +URL, the catalog needs its database's connection string, the cron job needs +credentials, and every one of those has to be repeated per environment. +Composer makes each of those edges a typed declaration and derives the rest. + +Two rules shape everything: + +- **Your code never reaches for its environment.** No `process.env`, no URLs. + Every dependency arrives typed, through one call — which is also why any + environment (production, a staging copy, a test) is just different injected + values. +- **The framework never bundles or transforms your code.** You build; the + deploy assembles what you built. ## What it looks like -A service declares its dependencies; app code receives them from one place, -`service.load()` — never `process.env`, never a URL: +A service declares its dependencies; app code receives them from +`service.load()`: ```ts // service.ts — the declaration: pure data export default compute({ name: 'storefront', - deps: { catalog: rpc(catalogContract) }, + deps: { catalog: rpc(catalogContract) }, // "I call catalog's API" build: nextjs({ module: import.meta.url, appDir: '..' }), }); -// app code — load() returns a typed client for the contract +// app code — load() returns a client typed by that contract const { catalog } = service.load(); const { products } = await catalog.listProducts({}); ``` @@ -74,9 +83,11 @@ flowchart TB Each box is a **Module**: a boundary that owns some code and data and is reachable only through typed ports. catalog and orders each own their own -Postgres internally — the root never sees them; the only edges are the -exposed, contract-typed RPC ports. Because nothing reaches inside a boundary, -every dependency in the app is an explicit, compiler-checked edge. +Postgres — a [Prisma Next](https://github.com/prisma/prisma-next)-typed one, +with migrations applied at deploy — and the root never sees them; the only +edges are the exposed, contract-typed RPC ports. Because nothing reaches +inside a boundary, every dependency in the app is an explicit, +compiler-checked edge. ## Getting started @@ -84,15 +95,16 @@ every dependency in the app is an explicit, compiler-checked edge. pnpm add @prisma/composer @prisma/composer-prisma-cloud arktype ``` -Follow **[Getting started](docs/guides/getting-started.md)** — empty directory -to a deployed two-service app. +Start with **[Getting started](docs/guides/getting-started.md)** — an empty +directory to a deployed two-service app, plus how to port an app you already +have. | Guide | Covers | | --- | --- | -| [Getting started](docs/guides/getting-started.md) | Your first app: contract → service → root module → deploy | -| [Building an app](docs/guides/building-an-app.md) | Contracts, databases (plain + Prisma Next-typed), reusable Modules, cron/storage/streams, config params, secrets, builds | +| [Getting started](docs/guides/getting-started.md) | Your first app end to end; porting an existing Node or Next.js app | +| [Building an app](docs/guides/building-an-app.md) | Contracts, databases (plain + Prisma Next-typed with migrations), reusable Modules, cron/storage/streams, config, secrets | | [Testing](docs/guides/testing.md) | Unit tests with `mockService`, integration tests with `bootstrapService` | -| [Deploying and operating](docs/guides/deploying.md) | Stages, destroy semantics, CI, production behavior | +| [Deploying and operating](docs/guides/deploying.md) | Stages, destroy, CI, how apps behave in production | ## Examples @@ -101,7 +113,7 @@ Complete, deployable apps under [`examples/`](examples/): | Example | Demonstrates | | --- | --- | | [pn-widgets](examples/pn-widgets/) | The minimal app: one service + one Prisma Next-typed Postgres | -| [storefront-auth](examples/storefront-auth/) | Next.js consumer + API producer, a reusable Module owning its database, secrets | +| [storefront-auth](examples/storefront-auth/) | Next.js frontend + API service, a reusable Module owning its database, secrets | | [store](examples/store/) | Four modules, typed databases with migrations, the shared cron module | | [cron](examples/cron/) | Scheduled jobs: `defineSchedule` + `serveSchedule` + the cron module | | [storage](examples/storage/) | The S3-backed blob store module | diff --git a/docs/guides/building-an-app.md b/docs/guides/building-an-app.md index a967463c..9bd7afdf 100644 --- a/docs/guides/building-an-app.md +++ b/docs/guides/building-an-app.md @@ -1,42 +1,40 @@ # Building an app -The authoring guide: everything a service can declare, how Modules compose, -and the building blocks that ship with the framework. It assumes you've been -through [Getting started](getting-started.md). +This guide covers everything you reach for once +[Getting started](getting-started.md) has shown you the shape: giving a +service a database (plain or Prisma Next-typed), packaging pieces as reusable +Modules, the cron/storage/streams modules that ship with the framework, +configuration, and secrets. -## The model in one paragraph +## How the pieces fit -A Prisma App is a tree of **Modules**. The leaves are **services** -(`compute()` — units that run your code) and **resources** (`postgres()` — -stateful things services depend on). Every dependency is declared on the -service and wired by a parent module; app code receives dependencies from -exactly one place, `service.load()`, and never reads `process.env` or looks a -service up by name. The framework never bundles or transforms your code — you -build it, the deploy assembles the built output -([ADR-0005](../design/90-decisions/ADR-0005-users-build-the-framework-assembles.md)). - -A service declaration: +A Prisma App is a tree of **Modules**. At the leaves are **services** — +`compute()`, the units that run your code — and **resources** — stateful +things like `postgres()`. A parent module wires them together; your code +never participates in the wiring, it just receives the results: ```ts compute({ - name: 'auth', // the node's name; also its default provision id - deps: { db: postgres() }, // typed dependency slots — read via load() - params: { region: string() }, // config values — read via config() - secrets: { key: secret() }, // secret needs — read via secrets() + name: 'auth', // the service's name in the app graph + deps: { db: postgres() }, // what it needs → read via service.load() + params: { region: string() }, // its config → read via service.config() + secrets: { key: secret() }, // its credentials → read via service.secrets() build: node({ module: import.meta.url, entry: '../dist/server.mjs' }), - expose: { rpc: authContract },// ports other services can depend on + expose: { rpc: authContract },// what it offers to other services }); ``` -`deps`, `params`, and `secrets` are three separate namespaces with three -separate accessors — a dependency and a config value never masquerade as each -other ([ADR-0021](../design/90-decisions/ADR-0021-params-are-read-through-config-not-load.md)). +Dependencies, config, and secrets are three deliberately separate things with +three separate accessors, so a database can never masquerade as a string and +a credential can never end up in ordinary config. Your code contains no +`process.env` reads and no URLs; that is what makes every environment — +production, a stage, a test — just a different set of injected values. ## Contracts -A **contract** types the edge between two services. The producer exposes and -serves it; a consumer depends on it and receives a typed client. The contract -lives with the service that owns it: +A **contract** is a service's API written as schemas, and it types both ends +of the edge: the producer's handlers and the consumer's client. Define it +once, in the package of the service that owns it: ```ts import { contract, rpc } from '@prisma/composer/rpc'; @@ -47,10 +45,10 @@ export const authContract = contract({ }); ``` -Producer side — `serve()` turns the service's `expose` into a fetch handler. -The handler map is keyed by the expose port's name, exhaustive at compile -time, and each handler receives the validated input (plus the service's own -loaded deps as a second argument): +On the producer, `serve()` turns the service's `expose` into a fetch handler. +The handler map must cover every method — a missing or wrong-shaped handler +doesn't compile. Each handler receives the validated input (and the service's +own loaded deps as a second argument): ```ts const handler = serve(service, { @@ -60,83 +58,96 @@ const handler = serve(service, { }); ``` -Consumer side — declare `deps: { auth: rpc(authContract) }` and -`service.load()` returns `{ auth }` as a typed client: `await -auth.verify({ token })`. Input and output are validated at the boundary at -runtime, against the same schemas that type both ends. +On the consumer, declare `deps: { auth: rpc(authContract) }` and +`service.load()` returns `{ auth }` — call `await auth.verify({ token })` +like a local function. Inputs and outputs are also validated at runtime, at +the boundary, against the same schemas. -RPC over HTTP is the only contract kind today — no gRPC, WebSocket, or -streaming contracts. +The only contract kind today is RPC over HTTP — no gRPC, WebSockets, or +streaming contracts yet. ## Databases -Two kinds of Postgres dependency, by how much the framework does for you: +There are two ways for a service to get a Postgres, depending on how much you +want the framework to do. + +### `postgres()` — bring your own client -**`postgres()`** — the binding is `{ url }` and the app owns its client -([ADR-0015](../design/90-decisions/ADR-0015-dependencies-resolve-to-bindings-clients-are-app-side.md)). -Build whatever client you like in the server entry: +The dependency delivers connection config — `{ url }` — and nothing else. You +build the client you already know (`pg`, Bun's `SQL`, an ORM) in your server +entry: ```ts import { SQL } from 'bun'; + const { db } = service.load(); const sql = new SQL({ url: db.url, max: 1, idleTimeout: 10 }); ``` -(Those pool settings matter in production — see +(Those pool settings are not decorative — Compute scales to zero and idle +connections get closed; see [Deploying and operating](deploying.md#production-behavior).) -**`pnPostgres(...)`** — a [Prisma Next](https://github.com/prisma/prisma-next)-typed -database ([ADR-0022](../design/90-decisions/ADR-0022-data-deps-carry-a-prisma-next-contract.md)): -`load()` returns a typed client constructed from your data contract, so -queries are compile-time checked against the schema — -`db.orm.public.Product.where({ id }).first()`, no SQL strings, no row -mapping. Three pieces: - -- `contract.prisma` — the schema. `prisma-next contract emit` turns it into - `contract.json` + `contract.d.ts`, which you wrap once: - - ```ts - // src/data.ts — the ONE value both ends reference - import { pnContract } from '@prisma/composer-prisma-cloud/prisma-next'; - import type { Contract } from '../contract.d.ts'; - import contractJson from '../contract.json' with { type: 'json' }; - - export const catalogData = pnContract(contractJson); - ``` - -- The dependency end: `deps: { db: pnPostgres(catalogData) }`. -- The resource end (in the module that owns the database): also names the - `prisma-next.config.ts` path, which the deploy's migration step loads to - find `migrations/`: - - ```ts - const db = provision(pnPostgres({ name: 'database', contract: catalogData, config })); - ``` - -Migrations are applied at deploy, before the service starts — no -`CREATE TABLE IF NOT EXISTS` in app code. The deploy refuses to wire a -service against a database with a different contract. -[`examples/store/modules/catalog`](../../examples/store/modules/catalog/) is -the complete pattern; [`examples/pn-widgets`](../../examples/pn-widgets/) is -the minimal one. +### `pnPostgres()` — a Prisma Next-typed database + +If you want typed queries and managed migrations, make the database a +[Prisma Next](https://github.com/prisma/prisma-next) one. `load()` then +returns a client generated from your schema — queries like +`db.orm.public.Product.where({ id }).first()` are compile-time checked, no +SQL strings, no row mapping. -## Modules +The workflow, once per schema change (all `prisma-next` commands — see the +Prisma Next docs for the details): -A Module is the unit of composition and reuse: it owns its internals (its -database, its services) and is reachable only through its typed boundary. +1. Edit `contract.prisma` — your schema. +2. `prisma-next contract emit` — regenerates `contract.json` + + `contract.d.ts` from it. +3. `prisma-next migration plan` — authors the migration into `migrations/`. +4. Deploy. The deploy applies `migrations/` before the service starts — + there's no `CREATE TABLE IF NOT EXISTS` anywhere in app code. -**A closed root** — no boundary, only provisions. This is the app: +In your app, the emitted contract is wrapped once, and that one value is +referenced by both the resource and every service that queries it: ```ts -export default module('my-app', ({ provision }) => { - const auth = provision(authModule); - provision(storefrontService, { deps: { auth: auth.rpc } }); -}); +// src/data.ts +import { pnContract } from '@prisma/composer-prisma-cloud/prisma-next'; +import type { Contract } from '../contract.d.ts'; +import contractJson from '../contract.json' with { type: 'json' }; + +export const catalogData = pnContract(contractJson); +``` + +The service depends on it: + +```ts +deps: { db: pnPostgres(catalogData) } ``` -**A reusable Module** — declares a boundary (what it needs and what it -offers) in the second argument, wires its internals in the builder, and -returns its exposed ports: +And the module that owns the database provisions it, also naming the +`prisma-next.config.ts` path so the deploy can find `migrations/`: + +```ts +const db = provision(pnPostgres({ name: 'database', contract: catalogData, config })); +``` + +Because both ends share the contract value, the deploy refuses to wire a +service against a database whose schema doesn't match. +[`examples/pn-widgets`](../../examples/pn-widgets/) is the minimal working +version; +[`examples/store/modules/catalog`](../../examples/store/modules/catalog/) is +the full pattern inside a reusable Module. + +## Reusable Modules + +When a service and its database belong together, package them as a +**Module**: a unit that owns its internals and offers only typed ports. A +consumer provisions the Module and wires its exposed contract — it never +sees, and can never reach, the database inside. + +A Module declares its boundary (what it needs, what it offers) in the second +argument, wires its internals in the builder function, and returns the ports +it promised: ```ts import { module, secret } from '@prisma/composer'; @@ -157,38 +168,48 @@ export default module( ); ``` -A consumer provisions this Module and wires `auth.rpc` — it never sees the -database. A Module can also declare boundary `deps`: inputs the parent -supplies, wired exactly like a service's. +The root then treats it like any other node: + +```ts +const auth = provision(authModule, { + secrets: { signingKey: envSecret('AUTH_SIGNING_SECRET') }, +}); +provision(storefrontService, { deps: { auth: auth.rpc } }); +``` -`provision(node, opts?)` accepts: +A Module can also declare boundary `deps` — inputs its parent must supply, +wired exactly like a service's. The root module you already have is just the +outermost Module, with no boundary at all. -- `id` — the provision's name in the graph; defaults to the node's own name. - Give an explicit id when the default would stutter (`auth.auth`). -- `deps` — wire each declared dependency slot to a provisioned ref or an - exposed port. -- `secrets` — bind each secret need (see § Secrets). +`provision(node, opts?)` takes: -One naming rule bites: the platform rejects provision names shorter than -three characters, so name a database `'database'`, not `'db'`. (The wiring -key — the service's own input name — can still be `db`.) +- `id` — the node's name in the graph, defaulting to its own `name`. Set it + explicitly when the default stutters (a service named `auth` inside a + module named `auth` would read as `auth.auth`). +- `deps` — a value for each declared dependency slot: another provision, or + an exposed port. +- `secrets` — a binding for each secret need (below). -## Shared modules +Two naming rules the platform enforces: provision names must be at least +three characters (call a database `'database'`, not `'db'` — the wiring key +on the service side can still be `db`), and ids must be unique within their +module. -First-party reusable Modules ship under `@prisma/composer-prisma-cloud`: +## The modules that ship with the framework -| Import | What it provisions | Exposes | +| Import | What you get | Exposes | | --- | --- | --- | -| `cron` from `/cron` | An always-on scheduler firing your schedule at your runner service | nothing | -| `storage` from `/storage` | An S3-backed blob store (own Postgres + minted credentials) | `store` | -| `streams` from `/streams` | Durable append-only event streams, backed by a `store` | `streams` | +| `cron` from `@prisma/composer-prisma-cloud/cron` | A scheduler that fires your jobs at your service on an interval | nothing | +| `storage` from `@prisma/composer-prisma-cloud/storage` | An S3-backed blob store, credentials included | `store` | +| `streams` from `@prisma/composer-prisma-cloud/streams` | Durable append-only event streams, backed by a `store` | `streams` | -Cron end to end. The schedule is the one source of truth for job ids and -intervals; `serveSchedule` is exhaustive over its ids at compile time, so an -unhandled job doesn't compile: +Cron is the one most apps want first. You supply two things — a schedule and +a runner service that exposes the `trigger` contract — and the module does +the rest: ```ts -// service.ts — the runner declares what the jobs need +// service.ts — the runner. The schedule is the single source of truth +// for job ids and intervals. import { defineSchedule, triggerContract } from '@prisma/composer-prisma-cloud/cron'; export const schedule = defineSchedule({ rotateSpecial: '30s' }); @@ -199,31 +220,36 @@ export default compute({ build: node({ module: import.meta.url, entry: '../dist/server.mjs' }), expose: { trigger: triggerContract }, }); +``` -// server.ts — map each job id to work +```ts +// server.ts — map each job id to work. serveSchedule checks the map +// against the schedule, so an unhandled job doesn't compile. import { serveSchedule } from '@prisma/composer-prisma-cloud/cron'; + const handler = serveSchedule(service, schedule, { rotateSpecial: (deps) => deps.catalog.rotateSpecial({}), }); +``` -// module.ts — the cron module's boundary deps mirror the runner's own, -// so the root wires them like any other edge +```ts +// module.ts — the cron module needs whatever your runner needs, so the +// root wires it like any other edge. provision(cron({ schedule, runner: promotionsService }), { deps: { catalog: catalog.rpc }, }); ``` -Storage and streams compose the same way — [`examples/storage`](../../examples/storage/) and -[`examples/streams`](../../examples/streams/) show the wiring, including the -streams module's secret binding. +[`examples/streams`](../../examples/streams/) show the other two, including +the streams module's secret binding. ## Config params -Params are caller-owned schemas on the declaration -([ADR-0018](../design/90-decisions/ADR-0018-config-params-carry-a-caller-owned-schema.md)): -the schema types the param and validates the value at boot. `string()` and -`number()` cover the scalars; `param(schema)` wraps any Standard Schema: +A param is a value you want to configure per environment without a redeploy +of code: a region, a feature flag, a job list. Declare it with a schema — +that schema gives you the TypeScript type *and* validates the stored value at +boot, so garbage config is a loud startup failure, not a runtime surprise: ```ts import { param, string } from '@prisma/composer'; @@ -239,38 +265,48 @@ compute({ }); ``` -Read them with `service.config()`. Facets are `default` and `optional` — they -describe how a value is handled, not what it is. Every service has a reserved -`port` param (default 3000); declaring your own `port` is an authoring error. +`string()` and `number()` cover the scalars; `param(schema)` takes any +Standard Schema. Params can have a `default` or be `optional`. Read them with +`service.config()` — they never appear in `load()`. + +Every service gets a reserved `port` param (default 3000); declaring your own +`port` is an authoring error, caught immediately. ## Secrets -Secret values never travel through framework config -([ADR-0029](../design/90-decisions/ADR-0029-secrets-are-a-forwardable-slot.md)). -A service declares a nameless **need**; the root binds it to a platform -env-var name; the value stays only in that platform variable: +Credentials get their own channel, separate from params, with one rule: +**the value never enters framework config**. The service declares a nameless +need, the root binds that need to a platform environment-variable name, and +the value only ever lives in that platform variable: ```ts -// the need (service.ts) +// the service: "I need a signing key" — no name, no value compute({ /* ... */ secrets: { signingKey: secret() } }); -// the binding (module.ts root) +// the root: "that need is the platform variable AUTH_SIGNING_SECRET" provision(authModule, { secrets: { signingKey: envSecret('AUTH_SIGNING_SECRET') } }); -// the read (server.ts) +// the server: the only way to the value is an explicit expose() const { signingKey } = service.secrets(); // SecretBox -signingKey.expose(); // the only way to the value +signingKey.expose(); ``` -The `SecretBox` redacts everywhere except `.expose()` — logging it, rendering -it, or JSON-serializing it never leaks the value. Modules forward needs -without ever learning the platform name (the auth Module above). A secret is -not a param; don't put credentials in `params`. +The `SecretBox` redacts on logging, rendering, and JSON-serialization — +leaking it takes a deliberate `.expose()`. A Module forwards a need up to its +parent without ever learning the platform name, which is what lets a reusable +Module require credentials without dictating your naming. + +At deploy time, the value is provisioned from the deploying shell's +environment — so CI (or you) exports `AUTH_SIGNING_SECRET`, and it never +appears in code, state, or generated config. ## Builds -The framework assembles only what you built. For plain server processes, the -shipped tsdown preset makes each entry self-contained (everything inlined +The framework never bundles your code — it assembles what your build +produced, byte for byte. Two build adapters ship: + +**`node` — any plain server process.** Point `entry` at a self-contained ESM +file. The shipped tsdown preset produces exactly that (everything inlined except runtime built-ins): ```ts @@ -278,23 +314,32 @@ import { prismaTsDownConfig } from '@prisma/composer/tsdown'; export default prismaTsDownConfig({ entry: { server: 'src/server.ts' }, outDir: 'dist' }); ``` -Two services in one package = two separate builds into separate `outDir`s. - -For Next.js, `next build` with `output: 'standalone'` is the whole build; -declare it with `nextjs({ module: import.meta.url, appDir: '..' })` from -`@prisma/composer/nextjs`, and add `nextjsBuild()` from -`@prisma/composer/nextjs/control` to the deploy config's `extensions`. One -Next-specific rule: a page that calls `service.load()` needs -`export const dynamic = 'force-dynamic'` — the runtime environment doesn't -exist at build time. - -## Further reading - -- [`docs/design/10-domains/core-model.md`](../design/10-domains/core-model.md) - — the complete type-level design. -- [`docs/design/10-domains/connection-contracts.md`](../design/10-domains/connection-contracts.md) - — contracts in depth. -- [`docs/design/10-domains/module-composition.md`](../design/10-domains/module-composition.md) - — boundaries, forwarding, nesting. -- [`docs/design/10-domains/config-params.md`](../design/10-domains/config-params.md) - — the config round trip, end to end. +Building two services from one package? Two separate builds into two +`outDir`s, so shared code lands in both. + +**`nextjs` — a Next.js app.** `next build` with `output: 'standalone'` is the +whole build; the adapter just needs to know where the app lives: + +```ts +build: nextjs({ module: import.meta.url, appDir: '..' }) +``` + +Add `nextjsBuild()` from `@prisma/composer/nextjs/control` to the deploy +config's `extensions`. And remember: any page or action that calls +`service.load()` needs `export const dynamic = 'force-dynamic'`. + +## Digging deeper + +The design record explains *why* the model is shaped this way: + +- [`core-model.md`](../design/10-domains/core-model.md) — the complete + type-level design. +- [`connection-contracts.md`](../design/10-domains/connection-contracts.md) — + contracts in depth. +- [`module-composition.md`](../design/10-domains/module-composition.md) — + boundaries, forwarding, nesting. +- [`config-params.md`](../design/10-domains/config-params.md) — the config + round trip, and [ADR-0029](../design/90-decisions/ADR-0029-secrets-are-a-forwardable-slot.md) + for the secrets model. +- [ADR-0005](../design/90-decisions/ADR-0005-users-build-the-framework-assembles.md) + — why you build and the framework assembles. diff --git a/docs/guides/deploying.md b/docs/guides/deploying.md index f452d67b..0d082727 100644 --- a/docs/guides/deploying.md +++ b/docs/guides/deploying.md @@ -1,108 +1,109 @@ # Deploying and operating -`prisma-composer` deploys the app whose root module is your entry file's -default export. Two commands, `deploy` and `destroy`; the target environment — -called a **stage** — is chosen on the command line, never in your code. +One CLI, two commands. `prisma-composer deploy` takes your entry file (the +one whose default export is the root module) and stands the whole app up on +Prisma Cloud; `prisma-composer destroy` tears an environment down. Which +environment you're touching — production or an isolated **stage** — is always +a command-line choice, never something in your code. | You want to… | Run | | --- | --- | | Deploy to production | `prisma-composer deploy module.ts` | | Deploy an isolated environment | `prisma-composer deploy module.ts --stage ` | -| Override the app name for one run | `prisma-composer deploy module.ts --name demo-42` | +| Deploy under a different app name | `prisma-composer deploy module.ts --name demo-42` | | Tear down an isolated environment | `prisma-composer destroy module.ts --stage ` | | Tear down production's resources | `prisma-composer destroy module.ts --production` | ## Credentials -A deploy needs exactly two environment variables: `PRISMA_SERVICE_TOKEN` and -`PRISMA_WORKSPACE_ID`. Nothing else — the CLI resolves the app's Project (by -the root module's name) and, for a named stage, the stage's Branch, creating -either if it doesn't exist yet. A fresh checkout with just those two variables -set deploys successfully. +Two environment variables, nothing else: -Keep the values out of the repo — an `.env` you source at deploy time, or CI -secrets. There is no interactive login; the static token is the only -authentication. +- `PRISMA_SERVICE_TOKEN` — create a service token for your workspace in the + [Prisma Console](https://console.prisma.io). +- `PRISMA_WORKSPACE_ID` — in the workspace's settings. + +A fresh checkout with just those two set deploys successfully — the CLI finds +or creates everything else. Keep the values out of the repo (an `.env` you +source at deploy time, or CI secrets). There's no interactive login; the +token is the only authentication. ## Build first -`prisma-composer deploy` does not build for you: +`prisma-composer deploy` does not build for you — it assembles what your +build produced: ```sh turbo run build && prisma-composer deploy module.ts ``` -The deploy assembles what your build produced (the `entry` each service's -build adapter names) and provisions it. Deploy state lives in a -workspace-hosted ledger (the `prismaState()` line in -`prisma-composer.config.ts`), shared by every deployer of the app — your -machine and CI see the same state, and concurrent deploys of the same stage -are locked against each other. +Deploy state (what's already provisioned, so re-deploys diff instead of +recreate) is stored in your workspace, not on your machine — that's the +`prismaState()` line in `prisma-composer.config.ts`. Everyone deploying the +app shares it, your laptop and CI see the same world, and two concurrent +deploys of the same environment lock each other out instead of corrupting it. + +## Production and stages -## Stages +Deploying with no `--stage` targets **production**. In Prisma Cloud terms: +the app is a Project (named after your root module), and production lives at +the Project level. -Same code, many environments. Deploying with no `--stage` targets -**production**, at the Project level. `--stage ` targets a **named -stage** — a Branch of that same Project, with its own compute, its own empty -database, and its own configuration -([ADR-0023](../design/90-decisions/ADR-0023-a-prisma-app-is-one-project-a-stage-is-a-branch.md), -[ADR-0024](../design/90-decisions/ADR-0024-a-stage-is-a-deploy-time-environment-resolved-to-project-and-branch.md)). -Every environment repeats the app's full topology; only the data and config -differ. +`--stage ` deploys a complete, isolated copy of the app — every +service, every database, its own configuration — as a Branch of that same +Project. Nothing is shared with production except the code: ```sh prisma-composer deploy module.ts # production -prisma-composer deploy module.ts --stage staging # an isolated "staging" environment -prisma-composer deploy module.ts --stage pr-42 # one isolated environment per PR +prisma-composer deploy module.ts --stage staging # a persistent staging environment +prisma-composer deploy module.ts --stage pr-42 # one environment per PR ``` -Re-deploying the same stage is idempotent: it finds the existing Project and -Branch and updates the resources inside them. A stage name must be a valid -git ref name (checked with `git check-ref-format`); an invalid name is a hard -error, never silently normalized. +Re-deploying any environment is idempotent — it updates the resources in +place. A stage name must be a valid git ref name (`git check-ref-format`); +an invalid name is a hard error, never a silent rename. -Each deployed service becomes a Compute service in the Project; its public -URL is its service endpoint domain, shown in the -[Prisma Console](https://console.prisma.io). +After a deploy, each service is a Compute service in the Project; its public +URL is its service endpoint domain, shown in the Console. -## Destroy requires an explicit target +## Destroying -A bare `prisma-composer destroy` is an error — name what you're tearing down: +`destroy` refuses to guess. A bare `prisma-composer destroy` is an error — +name the target: ```sh -prisma-composer destroy module.ts --stage staging # removes staging's resources, then its Branch -prisma-composer destroy module.ts --production # removes production's resources +prisma-composer destroy module.ts --stage staging # staging only; production untouched +prisma-composer destroy module.ts --production # production's resources ``` -`--stage` and `--production` together is also an error — pick one. Destroying -a named stage deletes its Branch after removing its resources; the production -Branch itself is never deleted, only the resources inside it. Destroy never -creates anything: destroying a stage that was never deployed fails with -"nothing deployed" rather than standing one up first. +`--stage` and `--production` together is an error too. Destroying a stage +removes its resources and then deletes its Branch; destroying production +removes the resources but the production Branch itself always survives. +Destroy never creates: tearing down a stage that was never deployed fails +with "nothing deployed" rather than provisioning one first. ## CI -The commands are the same in CI — set the two variables as CI secrets, build, -deploy. The per-PR pattern: +Nothing is CI-specific — set the two variables as CI secrets, build, run the +same commands. The per-PR environment pattern: ```sh prisma-composer deploy module.ts --stage "pr-$PR_NUMBER" # on push prisma-composer destroy module.ts --stage "pr-$PR_NUMBER" # on close ``` -Secrets your app declares (see -[Building an app § Secrets](building-an-app.md#secrets)) are bound to -platform env-var names by the root module; the deploy provisions their values -from the deploying shell's environment, so CI must also export those -variables (e.g. `AUTH_SIGNING_SECRET`). +One extra: if your app declares secrets (see +[Building an app § Secrets](building-an-app.md#secrets)), their values are +provisioned from the deploying shell's environment — so CI must export those +variables too (e.g. `AUTH_SIGNING_SECRET`), alongside the two credentials. ## Production behavior -Things every deployed app runs into: +What deployed apps actually run into, and what to do about it: -- **Compute scales to zero, and idle database connections are closed.** A - persistent client crashes into a 502 restart loop unless you keep the pool - reconnect-friendly and refuse to die on async errors: +- **Compute scales to zero, and idle database connections get closed.** A + long-lived client that treats a dropped connection as fatal will crash-loop + through 502s. Keep the pool small and reconnect-friendly, and don't let an + async error kill the process: ```ts const sql = new SQL({ url: db.url, max: 1, idleTimeout: 10 }); @@ -110,22 +111,23 @@ Things every deployed app runs into: process.on('unhandledRejection', (err) => console.error('unhandledRejection', err)); ``` -- **Bind `0.0.0.0`**, not loopback — Compute routes external HTTP to the VM, - so a loopback-only listener is unreachable. -- **Cold starts reset service-to-service connections.** A call into a - scaled-to-zero service can get `ECONNRESET` while it wakes; retry it. -- **The ingress buffers streaming responses.** An open SSE tail delivers - nothing and times out at 60s — don't build on streamed HTTP responses. -- **Next.js pages that call `service.load()` need - `export const dynamic = 'force-dynamic'`** — the runtime environment - doesn't exist at build time, and Next ignores runtime env for prerendered - routes. +- **Bind `0.0.0.0`, not loopback.** Compute routes external HTTP to the VM; a + `localhost` listener is unreachable from outside. +- **Calls into a sleeping service can get `ECONNRESET`** while it cold-starts. + Retry them. +- **Streaming responses don't stream.** The ingress buffers the response until + it completes, so an open SSE tail delivers nothing and times out at 60s. + Don't build on streamed HTTP responses. +- **Next.js: pages that call `service.load()` need + `export const dynamic = 'force-dynamic'`.** The runtime environment doesn't + exist at build time, and Next won't re-read it for prerendered routes. -The full catalogue of platform footguns, with diagnoses, is -[`gotchas.md`](../../gotchas.md) at the repo root. +When something misbehaves in ways these don't explain, check +[`gotchas.md`](../../gotchas.md) at the repo root — the catalogue of platform +footguns with diagnoses, kept current as we hit them. -## Full model +## The full picture [`docs/design/10-domains/deploy-cli.md`](../design/10-domains/deploy-cli.md) -— the pipeline, stages and containers, the destroy contract, the error -surface. +documents the deploy pipeline end to end — stages and containers, the destroy +contract, the error surface. diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index ac982767..d89b956a 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -1,11 +1,22 @@ # Getting started -From an empty directory to a deployed two-service Prisma App. You'll build a -`quotes` service that owns an RPC contract, a public `gateway` that calls it, -compose them in a root module, and deploy. +This guide takes you from an empty directory to a two-service app running on +Prisma Cloud. Along the way you'll meet every core idea once: a contract, a +service, a root module, a build, a deploy. At the end there's a section on +[porting an app you already have](#porting-an-existing-app). -Prerequisites: [Bun](https://bun.sh) (the server runtime used throughout), -pnpm or npm, and a Prisma Cloud workspace (for the deploy at the end). +The app is deliberately tiny — a `quotes` API and a public `gateway` that +calls it, no database — so you can see the whole shape at once. Adding a +Postgres (including a Prisma Next-typed one) is the first thing to do after, +and [Building an app](building-an-app.md#databases) covers it. + +You'll need: + +- [Bun](https://bun.sh) — Prisma Compute runs Bun, so that's what the server + code targets (`Bun.serve`), and it's the fastest way to run things locally. +- pnpm (or npm). +- For the deploy at the end: a Prisma Cloud workspace, plus a service token + and your workspace id from the [Prisma Console](https://console.prisma.io). ## 1. Project setup @@ -32,7 +43,7 @@ pnpm add -D tsdown typescript @types/bun } ``` -The layout you're about to create: +This is what you're about to create: ``` my-app/ @@ -41,9 +52,9 @@ my-app/ ├── tsdown.config.ts # your build └── src/ ├── quotes/ - │ ├── contract.ts # the service's public RPC contract - │ ├── service.ts # the declaration: deps + build + expose - │ └── server.ts # the entry your build produces + │ ├── contract.ts # the quotes service's public API, as types + │ ├── service.ts # what quotes is: deps + build + what it exposes + │ └── server.ts # the code that actually runs └── gateway/ ├── service.ts └── server.ts @@ -51,9 +62,9 @@ my-app/ ## 2. The quotes service -**The contract** — the typed edge other services depend on. It lives with the -service that owns it. Any Standard Schema validator works; arktype is what the -examples use: +Three files. First, the **contract** — the API other services will call, +written as schemas. It lives with the service that owns it. Any Standard +Schema validator works; the examples use arktype: ```ts // src/quotes/contract.ts @@ -65,8 +76,9 @@ export const quotesContract = contract({ }); ``` -**The declaration** — pure data: a name, dependencies (none yet), how the -service is built, and what it exposes: +Second, the **service declaration**. This is pure data — no behavior. It says +what the service is called, what it depends on (nothing yet), how it's built, +and which contract it exposes: ```ts // src/quotes/service.ts @@ -82,10 +94,10 @@ export default compute({ }); ``` -**The server entry** — what your build produces and the platform boots. -`serve()` generates the HTTP handler from the contract; the handler map is -checked against it at compile time, so a missing or wrong-shaped handler -doesn't compile: +Third, the **server** — the code your build turns into `dist/quotes/server.mjs` +and the platform boots. `serve()` generates the HTTP handler from the +contract; if you forget a handler or return the wrong shape, it doesn't +compile: ```ts // src/quotes/server.ts @@ -111,10 +123,17 @@ export default handler; Bun.serve({ port, hostname: '0.0.0.0', fetch: handler }); ``` +Notice what's missing: no port constant, no URL of anything, no +`process.env`. Configuration arrives through `service.config()`, dependencies +through `service.load()` — that's the whole framework contract with your +code. + ## 3. The gateway service -The gateway declares a dependency on the quotes contract and gets a typed -client back from `load()` — no URL, no fetch wrapper, no client setup: +The gateway depends on the quotes contract. Declaring +`deps: { quotes: rpc(quotesContract) }` means `service.load()` hands the +server a ready-made, typed client — calling it is just an async function +call: ```ts // src/gateway/service.ts @@ -147,13 +166,13 @@ Bun.serve({ }); ``` -Nothing exposes a contract here, so there's no `serve()` — the gateway is an -ordinary HTTP server that happens to receive a typed client. +The gateway exposes no contract of its own, so there's no `serve()` here — +it's an ordinary HTTP server that happens to receive a typed client. ## 4. Compose the app -The root module provisions both services and wires the exposed port into the -dependency slot. Its default export is the app: +The root module is the app. It provisions both services and wires the quotes +service's exposed port into the gateway's dependency slot: ```ts // module.ts @@ -167,8 +186,8 @@ export default module('my-app', ({ provision }) => { }); ``` -The deploy config sits next to it. It is read only by `prisma-composer -deploy`/`destroy` — app code never imports it: +Next to it goes the deploy config. Only `prisma-composer deploy`/`destroy` +read this file — your app code never imports it: ```ts // prisma-composer.config.ts @@ -182,12 +201,38 @@ export default defineConfig({ }); ``` -## 5. Build +## 5. Run it locally + +There's no dev server yet (it's on the roadmap) — but a server entry is just +a program, and locally `service.load()` and `service.config()` read plain +environment variables named after the slot: `COMPOSER_PORT` for the service's +own `port` param, `COMPOSER_QUOTES_URL` for the `quotes` dependency's URL. A +missing required value fails loudly at boot with the exact variable name it +wanted. + +Two terminals: + +```sh +# terminal 1 — quotes; port defaults to 3000 +bun run src/quotes/server.ts -You own the build; the framework only assembles what you built. The shipped -tsdown preset makes each entry self-contained. Two services means two separate -builds into separate output directories — not one multi-entry build, which -would split shared code (the contract) into a chunk neither output contains: +# terminal 2 — gateway on 3001, pointed at the local quotes +COMPOSER_PORT=3001 COMPOSER_QUOTES_URL=http://localhost:3000/ bun run src/gateway/server.ts + +curl localhost:3001 +# Make it work, make it right, make it fast. +``` + +In production nobody writes these variables by hand — the deploy provisions +them from the wiring in `module.ts`. Locally you *are* the deploy. + +## 6. Build and deploy + +You own the build — the framework only assembles what you built. The shipped +tsdown preset makes each entry a single self-contained file. Two services +means two builds into two output directories (not one multi-entry build, +which would split the shared contract code into a chunk neither output +contains): ```ts // tsdown.config.ts @@ -200,13 +245,12 @@ export default [ ``` ```sh -pnpm tsdown +pnpm exec tsdown ``` -## 6. Deploy - -A deploy needs exactly two environment variables — a service token and the -workspace id from your Prisma Cloud workspace: +Deploying needs exactly two environment variables. Create a service token in +your workspace in the [Prisma Console](https://console.prisma.io); the +workspace id is in the workspace's settings: ```sh export PRISMA_SERVICE_TOKEN=... @@ -215,30 +259,76 @@ export PRISMA_WORKSPACE_ID=... pnpm exec prisma-composer deploy module.ts ``` -The CLI resolves (or creates) the app's Project, provisions both services on -Prisma Compute, wires the gateway's `quotes` dependency to the deployed quotes -service, and starts them. Each service becomes a Compute service in the -Project; its public URL is its service endpoint domain, shown in the -[Prisma Console](https://console.prisma.io). Open the gateway's URL and you -get a quote — served over one typed RPC hop. +The CLI creates a Project named `my-app` in your workspace, provisions both +services on Prisma Compute, points the gateway's `quotes` dependency at the +deployed quotes service, and starts everything. Each service's public URL is +its Compute service endpoint, shown in the Console. Open the gateway's URL: +you get a quote, served over one typed RPC hop. -Re-deploying is idempotent: it updates the resources inside the same Project. -To stand up an isolated copy (same topology, separate everything), deploy a -**stage**, and tear it down when done: +Re-deploying is idempotent — it updates the same Project. For an isolated +copy of the whole app (own services, own config), deploy a **stage**, and +tear it down when you're done: ```sh pnpm exec prisma-composer deploy module.ts --stage demo pnpm exec prisma-composer destroy module.ts --stage demo ``` +## Porting an existing app + +You don't rewrite an app to put it on Composer — you declare it. The server +code you already have stays the server; you add the declaration around it. + +**A Node/Bun service.** Add a `service.ts` with `compute({ name, deps, build, +entry })` pointing at your built entry, then make three changes to the server +itself: + +1. Read the port from `service.config()` instead of `process.env.PORT`, and + bind `0.0.0.0`. +2. Replace every `process.env` read with a declared param (values you + configure), a secret (credentials — see + [Building an app § Secrets](building-an-app.md#secrets)), or a dependency. +3. If it talks to Postgres: declare `deps: { db: postgres() }` and build your + existing client (`pg`, Bun's `SQL`, whatever you use today) from the + injected `db.url` instead of a connection-string env var. + +Your build must produce a self-contained entry file — `prismaTsDownConfig` +does that; keep your own build if it already does. + +**A Next.js app.** Use the `nextjs` build adapter instead of `node`; `next +build` with `output: 'standalone'` is the whole build: + +```ts +export default compute({ + name: 'web', + deps: { api: rpc(apiContract) }, + build: nextjs({ module: import.meta.url, appDir: '..' }), +}); +``` + +Add `nextjsBuild()` from `@prisma/composer/nextjs/control` to the deploy +config's `extensions`. Any page or server action that calls `service.load()` +needs `export const dynamic = 'force-dynamic'`, because the runtime +environment doesn't exist at build time. +[`examples/storefront-auth`](../../examples/storefront-auth/) is a complete +ported-shaped app: a Next.js frontend calling a Bun API service that owns a +Postgres. + +**More than one service.** Port them into one `module.ts` and replace the +URLs they used to reach each other with contracts — that's the payoff: the +edges become typed, and every environment (production, stages, tests) gets +the wiring for free. + ## Where to go next -- [Building an app](building-an-app.md) — databases, reusable Modules, the - shared cron/storage/streams modules, config params, secrets. +- [Building an app](building-an-app.md) — databases (including Prisma + Next-typed ones with migrations), reusable Modules, cron/storage/streams, + config params, secrets. - [Testing](testing.md) — unit tests with `mockService`, integration tests with `bootstrapService`. -- [Deploying and operating](deploying.md) — stages, destroy semantics, CI, - production behavior. -- [`examples/`](../../examples/) — complete apps, from a single service with a - typed database ([pn-widgets](../../examples/pn-widgets/)) to a four-module - store with cron ([store](../../examples/store/)). +- [Deploying and operating](deploying.md) — stages, destroy, CI, how the app + behaves in production. +- [`examples/`](../../examples/) — complete apps: start with + [pn-widgets](../../examples/pn-widgets/) (one service + one Prisma + Next-typed database) or [store](../../examples/store/) (four modules, cron, + a Next.js storefront). diff --git a/docs/guides/testing.md b/docs/guides/testing.md index 809b7646..3a2cd93a 100644 --- a/docs/guides/testing.md +++ b/docs/guides/testing.md @@ -1,32 +1,34 @@ # Testing -An app's code gets its dependencies from exactly one place — `service.load()` -— and never as arguments or globals. So you test by deciding what `load()` -gives the code, and you leave the code under test unchanged. Pick the tool by -how much of the real path you want to run: +Your app's code gets every dependency from one call — `service.load()` — and +nothing from arguments or globals. That makes testing a matter of deciding +what `load()` returns; the code under test is never modified. There are two +tools, and you pick by how much of the real path you want to exercise: | You want to… | Use | From | | --- | --- | --- | -| Test a page / action / handler in isolation | `mockService` | `@prisma/composer/testing` | -| Run the real boot + request path against a fake dependency | `bootstrapService` | `@prisma/composer-prisma-cloud/testing` | +| Call a page / action / handler directly, dependencies faked | `mockService` | `@prisma/composer/testing` | +| Boot the real built entry and drive it over real HTTP | `bootstrapService` | `@prisma/composer-prisma-cloud/testing` | -The full model is in -[`docs/design/10-domains/testing.md`](../design/10-domains/testing.md). +Most tests are the first kind. Reach for the second when the thing you're +proving is the wiring itself — that the service boots, reads its config, +builds its clients, and answers requests. ## Unit tests — `mockService` -For code you call directly. `mockService(service, overrides)` returns a copy -of the service whose `load()` yields your doubles and whose `config()` yields -the param defaults overlaid with any overrides — one flat object; dependency -keys route to `load()`, param keys to `config()`. The doubles are type-checked -against the service's declared dependencies, so a wrong-shaped fake doesn't -compile. +`mockService(service, overrides)` returns a copy of the service whose +`load()` yields your fakes and whose `config()` yields param defaults overlaid +with any overrides (one flat object — dependency names route to `load()`, +param names to `config()`). The fakes are type-checked against the service's +declared dependencies, so a fake with the wrong shape doesn't compile. -Wiring the module substitution is your runner's job — `vi.mock` in Vitest, -`mock.module` in bun test; `mockService` only supplies the typed value: +Substituting the mocked service for the real one is your test runner's job — +`vi.mock` in Vitest, `mock.module` in bun test. A Vitest example, testing a +Next.js page: ```tsx // page.test.tsx +import { renderToString } from 'react-dom/server'; import { mockService } from '@prisma/composer/testing'; import realService from '../src/service.ts'; @@ -37,16 +39,21 @@ vi.mock('../src/service.ts', () => ({ })); import Page from './page.tsx'; -expect(renderToString(await Page())).toContain('Signed in: true'); + +it('renders the verified state', async () => { + expect(renderToString(await Page())).toContain('Signed in: true'); +}); ``` -No Postgres, no server, no environment — the page renders against the fake. +No server, no database, no environment — the page just renders against the +fake. ## Integration tests — `bootstrapService` -For the real request path: the service's actual built entry boots in-process, -exactly as a deployed boot would, against a config you choose. Point a -dependency at a stand-in you run, then drive real HTTP. Run under `bun test`: +`bootstrapService` boots the service's **real built entry** in-process, fed +the same way a deployed boot is fed — you just choose the values. Point a +dependency at a stand-in you run on a loopback port, then make real HTTP +requests. Run these under `bun test`: ```ts // service.integration.test.ts @@ -65,25 +72,24 @@ const res = await app.fetch(new Request(app.url)); expect(await res.text()).toContain('Signed in: true'); ``` -The rules: +Four things to know: -- **The service's own code is not modified** — it boots exactly as in - production; you only choose its configuration. -- **`service.port` must be concrete** — the entry self-listens; no - OS-assigned port is reported back. -- **No `close()`** — run each integration-test file in its own process (bun - test does), so the started server is cleaned up when the file ends. -- **Build first** — `bootstrapService` boots the *built* entry, so the test - task must depend on the build (turbo's `test` → `build` dependency handles - this in the examples). +- **Build first.** It boots the *built* entry, so the test task must depend + on the build (in the examples, turbo's `test` task depends on `build`). +- **Pass a concrete `service.port`.** The entry listens itself; there's no + OS-assigned port reported back. +- **There is no `close()`.** Run each integration-test file in its own + process — bun test does this per file — and the server dies with it. +- **The service's code is untouched.** If you find yourself editing + `server.ts` to make it testable, something upstream is wrong. -**Next.js services take a third argument** — a boot thunk — because the built -entry lives in Next's standalone output. Resolve it with -`standaloneServerPath` from `@prisma/composer/nextjs/control`, and hand the -port to Next explicitly (its standalone server binds `process.env.PORT`, not -the service's config): +**Next.js services need a third argument** — a boot function — because the +built entry lives inside Next's standalone output. Resolve it with +`standaloneServerPath`, and hand Next the port explicitly (its standalone +server reads `process.env.PORT`, not the service's config): ```ts +import { pathToFileURL } from 'node:url'; import { standaloneServerPath } from '@prisma/composer/nextjs/control'; await bootstrapService(storefront, config, async () => { @@ -92,21 +98,25 @@ await bootstrapService(storefront, config, async () => { }); ``` -The complete pattern is +The complete working version is [`examples/storefront-auth/modules/storefront/app/page.integration.test.ts`](../../examples/storefront-auth/modules/storefront/app/page.integration.test.ts). -## The fake you pass +## Writing good fakes -A dependency's type *is* its contract, so any value of that shape is a valid -double, checked by the compiler. Three levels of realism: +A dependency's type *is* its contract, so anything of that shape is a valid +fake, and the compiler holds it to that. In increasing order of realism: -- **A bare object** — `{ verify: async () => ({ ok: true }) }`. Fastest. -- **The real client over an in-memory handler** — runs JSON encoding + schema - validation, no socket. -- **A real local server** — the fake served on a loopback port over real HTTP - (what `bootstrapService` drives). +- **A bare object** — `{ verify: async () => ({ ok: true }) }`. Right for + most unit tests. +- **The real client over an in-memory handler** — exercises JSON encoding and + schema validation, still no socket. +- **A real local server** — the fake served over actual HTTP, which is what + `bootstrapService` drives. -Ship a dependency's fake from its own package as a `/fake` entry point, -outside `src/` so it never reaches production. The fake and the real service -then always share one contract — when the contract changes, both stop -compiling together. +One habit pays for all of this: ship each service's fake from its own package +as a `/fake` entry point (outside `src/`, so it can't reach production). +Fake and service then share one contract — when the contract changes, both +stop compiling at once, and every consumer's tests find out immediately. + +The reasoning behind the two-seam design is in +[`docs/design/10-domains/testing.md`](../design/10-domains/testing.md).