diff --git a/README.md b/README.md index 01d45082..48b4ae92 100644 --- a/README.md +++ b/README.md @@ -1,156 +1,142 @@ # Prisma Composer -**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. +**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 +`service.load()`: + +```ts +// service.ts — the declaration: pure data +export default compute({ + name: 'storefront', + deps: { catalog: rpc(catalogContract) }, // "I call catalog's API" + build: nextjs({ module: import.meta.url, appDir: '..' }), +}); + +// app code — load() returns a client typed by that 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 — 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 + +```sh +pnpm add @prisma/composer @prisma/composer-prisma-cloud arktype +``` + +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 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, CI, how apps behave in production | + +## 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 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 | +| [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..9bd7afdf --- /dev/null +++ b/docs/guides/building-an-app.md @@ -0,0 +1,345 @@ +# Building an app + +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. + +## How the pieces fit + +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 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 },// what it offers to other services +}); +``` + +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** 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'; +import { type } from 'arktype'; + +export const authContract = contract({ + verify: rpc({ input: type({ token: 'string' }), output: type({ ok: 'boolean' }) }), +}); +``` + +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, { + rpc: { + verify: async ({ token }) => ({ ok: await check(token) }), + }, +}); +``` + +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. + +The only contract kind today is RPC over HTTP — no gRPC, WebSockets, or +streaming contracts yet. + +## Databases + +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 + +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 are not decorative — Compute scales to zero and idle +connections get closed; see +[Deploying and operating](deploying.md#production-behavior).) + +### `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. + +The workflow, once per schema change (all `prisma-next` commands — see the +Prisma Next docs for the details): + +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. + +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 +// 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) } +``` + +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'; +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 }; + }, +); +``` + +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 } }); +``` + +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. + +`provision(node, opts?)` takes: + +- `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). + +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. + +## The modules that ship with the framework + +| Import | What you get | Exposes | +| --- | --- | --- | +| `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 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. 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' }); + +export default compute({ + name: 'promotions', + deps: { catalog: rpc(catalogContract) }, + build: node({ module: import.meta.url, entry: '../dist/server.mjs' }), + expose: { trigger: triggerContract }, +}); +``` + +```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({}), +}); +``` + +```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 }, +}); +``` + +[`examples/storage`](../../examples/storage/) and +[`examples/streams`](../../examples/streams/) show the other two, including +the streams module's secret binding. + +## Config params + +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'; +import { type } from 'arktype'; + +compute({ + name: 'scheduler', + params: { + jobs: param(type({ jobId: 'string', every: 'string' }).array()), + region: string({ optional: true }), + }, + // ... +}); +``` + +`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 + +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 service: "I need a signing key" — no name, no value +compute({ /* ... */ secrets: { signingKey: secret() } }); + +// the root: "that need is the platform variable AUTH_SIGNING_SECRET" +provision(authModule, { secrets: { signingKey: envSecret('AUTH_SIGNING_SECRET') } }); + +// the server: the only way to the value is an explicit expose() +const { signingKey } = service.secrets(); // SecretBox +signingKey.expose(); +``` + +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 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 +import { prismaTsDownConfig } from '@prisma/composer/tsdown'; +export default prismaTsDownConfig({ entry: { server: 'src/server.ts' }, outDir: 'dist' }); +``` + +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 new file mode 100644 index 00000000..0d082727 --- /dev/null +++ b/docs/guides/deploying.md @@ -0,0 +1,133 @@ +# Deploying and operating + +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 ` | +| 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 + +Two environment variables, nothing else: + +- `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 — it assembles what your +build produced: + +```sh +turbo run build && prisma-composer deploy module.ts +``` + +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 + +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. + +`--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 # a persistent staging environment +prisma-composer deploy module.ts --stage pr-42 # one environment per PR +``` + +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. + +After a deploy, each service is a Compute service in the Project; its public +URL is its service endpoint domain, shown in the Console. + +## Destroying + +`destroy` refuses to guess. A bare `prisma-composer destroy` is an error — +name the target: + +```sh +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 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 + +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 +``` + +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 + +What deployed apps actually run into, and what to do about it: + +- **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 }); + 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; 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. + +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. + +## The full picture + +[`docs/design/10-domains/deploy-cli.md`](../design/10-domains/deploy-cli.md) +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 new file mode 100644 index 00000000..d89b956a --- /dev/null +++ b/docs/guides/getting-started.md @@ -0,0 +1,334 @@ +# Getting started + +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). + +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 + +```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"] +} +``` + +This is what 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 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 +``` + +## 2. The quotes service + +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 +import { contract, rpc } from '@prisma/composer/rpc'; +import { type } from 'arktype'; + +export const quotesContract = contract({ + random: rpc({ input: type({}), output: type({ quote: 'string' }) }), +}); +``` + +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 +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 }, +}); +``` + +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 +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 }); +``` + +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 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 +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); + }, +}); +``` + +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 is the app. It provisions both services and wires the quotes +service's exposed port into the gateway's dependency slot: + +```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 } }); +}); +``` + +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 +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. 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 + +# 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 +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 exec tsdown +``` + +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=... +export PRISMA_WORKSPACE_ID=... + +pnpm exec prisma-composer deploy module.ts +``` + +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 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 (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, 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 new file mode 100644 index 00000000..3a2cd93a --- /dev/null +++ b/docs/guides/testing.md @@ -0,0 +1,122 @@ +# Testing + +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 | +| --- | --- | --- | +| 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` | + +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` + +`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. + +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'; + +vi.mock('../src/service.ts', () => ({ + default: mockService(realService, { + auth: { verify: async () => ({ ok: true }) }, + }), +})); + +import Page from './page.tsx'; + +it('renders the verified state', async () => { + expect(renderToString(await Page())).toContain('Signed in: true'); +}); +``` + +No server, no database, no environment — the page just renders against the +fake. + +## Integration tests — `bootstrapService` + +`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 +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'); +``` + +Four things to know: + +- **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 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 () => { + process.env.PORT = String(PORT); + await import(pathToFileURL(standaloneServerPath(storefront.build)).href); +}); +``` + +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). + +## Writing good fakes + +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 }) }`. 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. + +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). diff --git a/skills/README.md b/skills/README.md new file mode 100644 index 00000000..f2c8ac3a --- /dev/null +++ b/skills/README.md @@ -0,0 +1,49 @@ +# 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 + +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 + 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.