@prisma/composer 0.16.0-dev.1 → 0.16.0-dev.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prisma/composer",
3
- "version": "0.16.0-dev.1",
3
+ "version": "0.16.0-dev.3",
4
4
  "type": "module",
5
5
  "description": "Prisma Composer — build a Prisma App by composing Modules. Core authoring, deploy pipeline, and the service-rpc/node/nextjs authoring surfaces. The `prisma-composer` CLI lives in @prisma/composer-cli.",
6
6
  "exports": {
@@ -39,15 +39,15 @@
39
39
  },
40
40
  "devDependencies": {
41
41
  "@effect/vitest": "4.0.0-rc.112",
42
- "@internal/assemble": "0.16.0-dev.1",
43
- "@internal/cli": "0.16.0-dev.1",
44
- "@internal/core": "0.16.0-dev.1",
45
- "@internal/foundation": "0.16.0-dev.1",
46
- "@internal/lowering": "0.16.0-dev.1",
47
- "@internal/nextjs": "0.16.0-dev.1",
48
- "@internal/node": "0.16.0-dev.1",
49
- "@internal/service-rpc": "0.16.0-dev.1",
50
- "@internal/tsdown-config": "0.16.0-dev.1",
42
+ "@internal/assemble": "0.16.0-dev.3",
43
+ "@internal/cli": "0.16.0-dev.3",
44
+ "@internal/core": "0.16.0-dev.3",
45
+ "@internal/foundation": "0.16.0-dev.3",
46
+ "@internal/lowering": "0.16.0-dev.3",
47
+ "@internal/nextjs": "0.16.0-dev.3",
48
+ "@internal/node": "0.16.0-dev.3",
49
+ "@internal/service-rpc": "0.16.0-dev.3",
50
+ "@internal/tsdown-config": "0.16.0-dev.3",
51
51
  "@types/node": "^26.0.1",
52
52
  "tsdown": "^0.22.7",
53
53
  "typescript": "^6.0.3"
@@ -0,0 +1,478 @@
1
+ ---
2
+ name: prisma-composer-core-concepts
3
+ metadata:
4
+ library: "@prisma/composer"
5
+ library_version: "0.16.0-dev.3"
6
+ version: 2026.9.1
7
+ description: >-
8
+ Use when deploying or managing an app that uses Prisma Composer
9
+ (`@prisma/composer`): wiring its services and Modules, running it locally,
10
+ testing composed services, or standing up / tearing down an environment.
11
+ Triggers on "prisma composer", "@prisma/composer", "prisma app", the
12
+ `prisma-composer` CLI, `compute()`, `module()`, `contract()`,
13
+ `service.load()`, `mockService`, `bootstrapService`.
14
+ ---
15
+
16
+ # Prisma Composer core concepts
17
+
18
+ A **Prisma App** is a tree of typed declarations composed in TypeScript and
19
+ handed to the `prisma-composer` CLI. This file covers structures,
20
+ hierarchies, relationships, and workflows: the concepts you cannot observe
21
+ from the code or the CLI's help output. It is not a CLI reference; discover
22
+ any individual command and its flags with `--help`. Commands named here
23
+ belong to the `prisma-composer` CLI itself; a host CLI that embeds Composer
24
+ may not carry every verb, so confirm a command exists via `--help` rather
25
+ than inferring it. The Prisma platform moves fast, so treat this file as the
26
+ stable conceptual core and find current, fuller documentation at
27
+ <https://www.prisma.io/docs>. For working code, read `examples/` in the
28
+ prisma/composer repo.
29
+
30
+ Two principles govern everything and are binding
31
+ (`docs/design/01-principles/`):
32
+
33
+ 1. **Your code never reads its environment.** Dependencies, configuration,
34
+ credentials, and the port all arrive through the service node, typed.
35
+ `process.env` is never the answer.
36
+ 2. **Composer never bundles or transforms your code.** You build with your own
37
+ bundler; the framework assembles the built output by deterministic steps
38
+ and hands it to the configured deploy target.
39
+
40
+ ## Declarations are data
41
+
42
+ Everything you author is a declaration: plain data describing a piece of the
43
+ app, executing nothing when imported. Three node kinds exist:
44
+
45
+ | Kind | Declared with | Purpose |
46
+ | --- | --- | --- |
47
+ | Service | `compute()` | A running unit of your code; atomic, Composer sees only its ports |
48
+ | Resource | `rawPostgres()`, `bucket()` | A stateful managed dependency |
49
+ | Module | `module()` | A grouping boundary; runs no code of its own, exposes typed ports |
50
+
51
+ Nodes connect through **ports**: `deps` declares what a node requires,
52
+ `expose` declares what it offers. Wiring happens in a Module's builder via
53
+ `provision()`, and the root Module, handed to the CLI, is the App:
54
+
55
+ ```ts
56
+ // module.ts
57
+ import { module } from '@prisma/composer';
58
+
59
+ export default module('store', ({ provision }) => {
60
+ const catalog = provision(catalogModule);
61
+ provision(storefrontService, { deps: { catalog: catalog.rpc } });
62
+ });
63
+ ```
64
+
65
+ Because ports are typed, **the compiler verifies every wire**. A dependency
66
+ wired to the wrong producer, a missing RPC handler, a literal input value of
67
+ the wrong shape: all of it fails `tsc`, not the deploy. Env-bound input is
68
+ the exception: those values exist only at deploy, so secret-binding
69
+ mismatches and missing platform variables surface as early deploy-time
70
+ refusals instead (see Two channels below). Typecheck, then build, then
71
+ deploy; don't use the cloud to find out whether the wiring is correct.
72
+
73
+ Composer itself is target-agnostic: `@prisma/composer` carries authoring,
74
+ testing, and the CLI, coupled to no platform. A deploy target is an extension
75
+ registered in the deploy config; `@prisma/composer-prisma-cloud` is the
76
+ Prisma Cloud target and the one this skill's deploy sections assume. Its
77
+ root exports `compute`, `rawPostgres`, `bucket`, `envSecret`, and
78
+ `envParam`; the ORM vocabulary (`postgres`, `dataContract`) lives under the
79
+ `/orm` subpath, alongside the shared `/cron`, `/storage`, `/streams`,
80
+ `/auth`, and `/email` modules. These are the only two Composer packages a
81
+ basic Prisma Cloud app needs, and nothing installs them for you: a fresh
82
+ project starts with neither, so add both as dependencies first. An
83
+ extension adds its own `prisma-composer-*` package alongside them. Compose
84
+ an existing Module before implementing a capability yourself; wiring one in
85
+ is a couple of lines.
86
+
87
+ Within the entry graph (everything reachable from `module.ts`), write
88
+ relative imports with explicit `.ts` extensions (`./service.ts`, with
89
+ `allowImportingTsExtensions` in tsconfig): that form resolves everywhere.
90
+ The `prisma-composer` CLI also maps `./service.js` and extensionless
91
+ `./service` to the `.ts` source, but other hosts may not.
92
+
93
+ ## The service node is the only doorway
94
+
95
+ Your runtime code receives everything from the service declaration it
96
+ imports:
97
+
98
+ 1. `service.load()`: dependencies (typed RPC clients, database bindings).
99
+ 2. `service.input()`: the whole input as one schema-validated object;
100
+ credentials in it are redacting `SecretString` boxes.
101
+ 3. `service.port()`: the reserved port to bind (default 3000).
102
+
103
+ A service declaration is pure data; the server entry is what your build
104
+ produces and the platform boots:
105
+
106
+ ```ts
107
+ // service.ts
108
+ export default compute({
109
+ name: 'auth',
110
+ deps: { db: rawPostgres() },
111
+ build: node({ module: import.meta.url, entry: '../dist/server.mjs' }),
112
+ expose: { rpc: authContract },
113
+ });
114
+
115
+ // server.ts
116
+ const { db } = service.load(); // { url }: you construct your own client
117
+ const handler = serve(service, {
118
+ rpc: { verify: async ({ token }) => ({ ok: token.length > 0 }) },
119
+ });
120
+ Bun.serve({ port: service.port(), hostname: '0.0.0.0', fetch: handler });
121
+ ```
122
+
123
+ The consumer declares `deps: { auth: rpc(authContract) }` and gets a typed
124
+ client back from `load()`.
125
+
126
+ ## Two channels: dependencies and input
127
+
128
+ | The value is… | Declare | Provide | Read |
129
+ | --- | --- | --- | --- |
130
+ | produced by another node | `deps: { db: rawPostgres() }` | wire at `provision()` | `load()` |
131
+ | anything else (config or credential) | one field of the `input` schema | bind at `provision()`: literal, `envParam()`, or `envSecret()` | `input()` |
132
+
133
+ The service declares its whole incoming configuration, plain values and
134
+ credentials together, as **one [Standard Schema](https://standardschema.dev)**
135
+ (arktype is the house choice). A credential is a field typed as
136
+ `secretString()` from `@prisma/composer/arktype`; conditional legality ("no
137
+ stripe key unless billing is on") is an ordinary schema union. The binding at
138
+ `provision()` mirrors the schema's shape; `envSecret('NAME')` names the
139
+ platform variable and never carries the value.
140
+
141
+ Rules that bite:
142
+
143
+ 1. **Secretness is enforced by validation.** A literal bound where the schema
144
+ expects `SecretString` fails the deploy; `envSecret` bound to a plain
145
+ string field fails the same way.
146
+ 2. **`envParam` values arrive as raw strings**; bind them to string fields.
147
+ The stage's platform variable is the store; the deploying shell only seeds
148
+ a missing name (and the deploy fails early, naming the variable, when both
149
+ lack it). Changing the platform value needs a redeploy.
150
+ 3. **Absence is the schema's call.** An env-bound field whose variable is
151
+ unset or empty resolves to *key omitted*, which is legal only if the
152
+ schema allows it (optional field, union arm). The deploy report prints the
153
+ serialized input document (secrets ride as `{"$secret":"VAR"}` pointers)
154
+ and every key that resolved absent.
155
+ 4. **The reserved `port` is outside the schema.** Read it through
156
+ `service.port()`, never `process.env`. The framework also exports `PORT`
157
+ for Next.js standalone, which binds it itself.
158
+ 5. **A Module forwards a secret need without learning the platform name.**
159
+ Declare `secrets: { signingKey: secret() }` on the Module boundary and
160
+ pass the forwarded ref as a binding leaf; the parent binds the real
161
+ source.
162
+ 6. `input.apiKey.expose()` is the only way to a secret's value; the box
163
+ redacts everywhere else (logs, JSON, errors).
164
+
165
+ ## Contracts and RPC
166
+
167
+ A contract is the typed interface through which services communicate. It
168
+ lives with the service that owns it, typed by any Standard Schema validator,
169
+ and both provider (`serve()`, exhaustive over the contract's methods at
170
+ compile time) and consumer (`rpc(contract)`) reference the same value. Calls
171
+ travel as RPC over HTTP. Two behaviours are provisioned for you and must not
172
+ be reimplemented:
173
+
174
+ 1. **Service keys.** At deploy, Composer mints a distinct unguessable key per
175
+ consumer→provider binding; `serve()` returns `401` to anything else before
176
+ the handler runs. Nothing in your code declares it. Consequences: don't
177
+ build your own service-to-service auth, and don't `curl` a deployed
178
+ `/rpc/<method>` to check it works. An unwired caller always gets `401`,
179
+ which looks like a broken deploy and isn't. Debug through a consumer, or
180
+ locally, where nothing is enforced. Keys are per binding (one leaking
181
+ can't impersonate another consumer), service-scoped (any valid key
182
+ reaches every method; split services to gate separately), rotated only by
183
+ removing the binding or destroying the stack and redeploying, and stored
184
+ in deploy-owned `COMPOSER_*` variables you never hand-edit.
185
+ 2. **Idempotency and retries.** Every generated-client call carries an
186
+ `Idempotency-Key`; dropped calls retry with backoff, and `serve()` runs
187
+ one call per key, replaying the completed answer to late retries. Every
188
+ method is therefore safely retryable and no contract declares anything
189
+ about it (there is no "is this idempotent" flag; don't invent one). A
190
+ handler may take an optional third argument `(input, deps, ctx)` and read
191
+ `ctx.idempotencyKey` (`string | undefined`) if it needs exactly-once
192
+ beyond one instance's memory; most don't. Locally and in tests nothing is
193
+ provisioned, so `serve()` passes every call through: never supply a key
194
+ in test inputs.
195
+
196
+ ## Builds are yours
197
+
198
+ You build, the framework assembles. For a plain server process, `entry` must
199
+ point at a single self-contained ESM file: everything inlined except runtime
200
+ built-ins (`bun`, `bun:*`, `node:*`). Deploy copies that one file and never
201
+ ships `node_modules`, so anything left un-inlined fails at boot, not at
202
+ deploy. Rules that bite:
203
+
204
+ 1. **Two services in one package means two separate builds**, one per entry.
205
+ A single multi-entry build splits shared code into a chunk neither output
206
+ contains.
207
+ 2. **A directory build uses `dir` + `entry`** (`dir` relative to the service
208
+ module, `entry` a file inside `dir`; `../` is an error). The tree is
209
+ copied verbatim, so the server must resolve siblings against
210
+ `import.meta.url`, not the working directory. The tree must contain no
211
+ symlinks: the packager rejects them, names the link, and assembly fails.
212
+ 3. **Next.js**: `next build` with `output: 'standalone'` is the whole build;
213
+ `nextjs({ module, appDir })` names the app root. Any page or action that
214
+ calls `load()` needs `export const dynamic = 'force-dynamic'`, because
215
+ the runtime environment doesn't exist at build time and Next ignores
216
+ runtime env for prerendered routes.
217
+ 4. **Always build before `deploy` or `dev`.** Neither builds for you.
218
+
219
+ Deploy configuration lives in `prisma-composer.config.ts` (or `.mts`, `.mjs`,
220
+ `.js`; nearest ancestor of the entry wins, `.ts` first within a directory).
221
+ It registers extensions (`prismaCloud()`, `nodeBuild()`, `nextjsBuild()` when
222
+ the app has a Next.js service) and the deploy-state backend
223
+ (`prismaState()`). It is read by the CLI's operations (deploy, destroy, and
224
+ dev; a `dev` run without one refuses, naming the missing file) and never
225
+ imported by app code.
226
+
227
+ ## Databases and migrations
228
+
229
+ Two kinds of Postgres dependency:
230
+
231
+ 1. **`rawPostgres()`**: the binding is `{ url }` and the app owns its client.
232
+ 2. **`postgres(...)`**: a Prisma-ORM-typed database. The binding is
233
+ `{ url, client }` (ADR-0040): the raw connection URL plus the typed
234
+ client Composer constructs from your data contract, lazily on first
235
+ access, so queries go through `binding.client` and are compile-time
236
+ checked. Both `postgres` and `dataContract` import from
237
+ `@prisma/composer-prisma-cloud/orm`, not the package root. One
238
+ `dataContract`-wrapped value (emitted from `contract.prisma` by
239
+ `prisma contract emit`) is referenced by both the dependency end
240
+ (`deps: { db: postgres(catalogData) }`) and the resource end, which also
241
+ names the `prisma.config.ts` path so the deploy's migration step can
242
+ find `migrations/`.
243
+
244
+ **Deploys are replay-only**: they apply the migrations committed under
245
+ `migrations/` and never create schema themselves. Every schema change,
246
+ including the first schema of a new database, follows one loop:
247
+
248
+ 1. Edit `contract.prisma`.
249
+ 2. `prisma contract emit` regenerates `contract.json` + `contract.d.ts`.
250
+ 3. `prisma migration plan --name <slug>` authors the migration (on an empty
251
+ graph this authors the baseline).
252
+ 4. Commit `migrations/` with the change, then deploy. A fresh database
253
+ replays the whole path from empty.
254
+
255
+ If no authored path reaches the target contract, deploy (and `dev` against a
256
+ stale local database) refuses with `MIGRATION_PATH_NOT_FOUND`; its message
257
+ lists the two ways out: author the missing migration, or, when iterating
258
+ against a local
259
+ database only, `prisma db update`. Never skip step 3 before a deploy. See
260
+ `examples/store/modules/catalog` for the complete pattern.
261
+
262
+ ## Deploy model: converge, don't script
263
+
264
+ Deploy compares the declared topology against recorded deploy state and
265
+ applies only the difference. Re-deploying with nothing changed is a no-op;
266
+ removing a node removes its deployed resource. The Prisma Cloud target
267
+ requires exactly two environment variables: `PRISMA_SERVICE_TOKEN` and
268
+ `PRISMA_WORKSPACE_ID`. There is no interactive login.
269
+
270
+ **Stages.** A stage is an environment name chosen on the command line at
271
+ deploy time, never written in the topology. The identical graph deploys
272
+ everywhere. On the Prisma Cloud target, a Prisma App is one Project and a
273
+ stage is a Branch of it, with its own running services, its own empty
274
+ database, its own configuration. A stage name must be a valid git ref name;
275
+ an invalid name is a hard error.
276
+
277
+ **Destroy** always requires an explicit target: a bare destroy is an error,
278
+ and naming a stage and production together is too. Destroying a stage
279
+ deletes its Branch after removing its resources. Destroying production
280
+ removes only the resources inside the production Branch, never the Branch
281
+ itself directly; once the Project is empty it is deleted too, and that
282
+ deletion takes the production Branch with it. A Project still holding
283
+ another stage's resources is kept. Destroy never creates anything:
284
+ destroying a
285
+ never-deployed stage fails rather than standing one up.
286
+
287
+ **The engine underneath is alchemy.** Convergence is executed by
288
+ [alchemy](https://alchemy.run), a third-party infrastructure-as-code engine
289
+ that arrives as an ordinary, exactly-pinned npm dependency of
290
+ `@prisma/composer` (2.0.0-beta.74 at this library version). Your code never
291
+ imports or configures it; consult alchemy's own docs for the engine itself.
292
+ What matters operationally:
293
+
294
+ 1. Deploy and destroy write the pipeline's results to a generated, gitignored
295
+ stack file at `.prisma-composer/alchemy.run.ts`, then run the alchemy CLI
296
+ against it as a child process; `dev` does the same at
297
+ `.prisma-composer/dev/alchemy.run.ts` with local providers. The file
298
+ carries the computed values as literals but reads credentials via
299
+ `fromEnv()`, so nothing sensitive lands on disk, and it is regenerated
300
+ every run: output, not configuration, never edited.
301
+ 2. Failures are bisectable through that file. A failing deploy names its
302
+ path; running `alchemy deploy .prisma-composer/alchemy.run.ts` directly
303
+ separates "the framework computed the wrong thing" from "the engine or
304
+ platform rejected the right thing". An engine failure surfaces as
305
+ `DEPLOY.ENGINE_FAILED` carrying the exit code and that reproduce command;
306
+ the child's live output streams to the terminal either way.
307
+ 3. Destroy evaluates the same stack program as deploy, and evaluating it
308
+ packages the assembled bundles, so **an app must be built before it can
309
+ be torn down**.
310
+ 4. alchemy is why the `effect` pin exists: it resolves the `effect`
311
+ constellation, and a hoisted newer `effect` halts every command (failure
312
+ mode 1 below).
313
+
314
+ **The deploy report** ends with the app's own topology: authored names, the
315
+ platform resource each became, and public URLs. Read ids out of it rather
316
+ than hunting in the Console. A URL appears only where the address is
317
+ genuinely public: a service prints one, a database never does, and a
318
+ node whose product is secret material reports no resource line at all.
319
+
320
+ **Connection contract refusals.** A connection declares the values it needs
321
+ by name; a producer that omits one fails the deploy, naming the edge, the
322
+ param, and what the producer did supply:
323
+
324
+ ```text
325
+ Connection input "auth.db" declares param "url", but its producer "db" did not
326
+ supply it — the producer's outputs carry [host].
327
+ ```
328
+
329
+ This is a deploy-time refusal, not a broken deploy, and it can appear on an
330
+ app whose code didn't change (the gap used to pass silently as `undefined`
331
+ and crash the consumer at boot). Fix whichever end is wrong; don't mark the
332
+ param `optional` unless absent really is legal. Only reachable if you
333
+ authored the connection or an extension on one side.
334
+
335
+ **Driving deploys from code.** `@prisma/composer/control` exposes typed
336
+ `deploy`, `destroy`, `dev`, and `log` returning structured results. Failures
337
+ come back as `{ ok: false, failure }` with a dotted `failure.code` from a
338
+ closed registry (e.g. `ASSEMBLE.BUILD_FAILED`, `DEPLOY.ENGINE_FAILED`,
339
+ `DEPS.EFFECT_VERSION_CONFLICT`); branch on the code, not the message. A
340
+ non-structured rejection out of an operation is a bug in composer, not an
341
+ expected failure.
342
+
343
+ ## Local development
344
+
345
+ The `dev` command runs the whole app on this machine, wired as it deploys,
346
+ against local emulators. No cloud credentials are needed or read. Concepts
347
+ that surprise:
348
+
349
+ 1. It runs the same pipeline as deploy, so **build first**, exactly like
350
+ deploy. It watches built output and restarts a service when its build
351
+ changes.
352
+ 2. Ctrl-C stops the app's processes but leaves local databases, buckets, and
353
+ their data up: the next `dev` is a warm start. Starting clean, wiping
354
+ this app's local instances and data first, is an explicit opt-in flag.
355
+ 3. `dev` does not print service logs; `log` is a separate, read-only command
356
+ that follows the already-running app's merged logs. It never builds,
357
+ provisions, starts, or stops anything.
358
+ 4. An unset secret doesn't block a local run: it becomes a placeholder plus a
359
+ warning, and only the code path that spends it fails, at the external
360
+ service it calls.
361
+ 5. Windows isn't supported yet.
362
+
363
+ ## Testing is an environment seam
364
+
365
+ A test is just another environment: one where you decide what `load()` and
366
+ `input()` return, never by editing the code under test.
367
+
368
+ | You want to… | Use | From |
369
+ | --- | --- | --- |
370
+ | Test a page / action / handler in isolation | `mockService` | `@prisma/composer/testing` |
371
+ | Run the real boot + request path against a fake dependency | `bootstrapService` | `@prisma/composer-prisma-cloud/testing` |
372
+
373
+ `mockService` returns a copy of the service whose `load()` yields your
374
+ doubles (type-checked against the declared deps) and whose `input()` yields
375
+ the object passed under the reserved `input` key (required exactly when the
376
+ service declares an input schema; handed over as-is, not validated). Wiring
377
+ the module substitution is your runner's job (`vi.mock` in Vitest,
378
+ `mock.module` in bun test).
379
+
380
+ `bootstrapService` boots the service's real built entry in-process against a
381
+ config you choose; drive it over real HTTP. Gotchas:
382
+
383
+ 1. `service.port` must be concrete: the entry self-listens, and no
384
+ OS-assigned port is reported back.
385
+ 2. There is no `close()`; run each integration-test file in its own process
386
+ (bun test does).
387
+ 3. Next.js services take a third argument, a boot thunk, resolved with
388
+ `standaloneServerPath` from `@prisma/composer/nextjs/control`.
389
+ 4. A service with an input schema takes `input` in the config, a binding
390
+ exactly like `provision()`'s, run through the real serialize/read path.
391
+
392
+ A dependency's type is its contract, so any value of that shape is a valid
393
+ double: a bare object, the real client over an in-memory handler, or a real
394
+ local server. Ship a dependency's fake from its own package as a `/fake`
395
+ entry point, outside `src/`, so the fake and the real service share one
396
+ contract.
397
+
398
+ ## Building blocks and extensions
399
+
400
+ First-party Modules ship inside `@prisma/composer-prisma-cloud` and
401
+ provision exactly like your own:
402
+
403
+ | Import | What it provisions | Exposes |
404
+ | --- | --- | --- |
405
+ | `cron` from `/cron` | An always-on scheduler firing your schedule at your runner service | nothing |
406
+ | `storage` from `/storage` | An S3-backed blob store (own Postgres + minted credentials) | `store` |
407
+ | `streams` from `/streams` | Durable append-only event streams over a `store` | `streams` |
408
+ | `auth` from `/auth` | Signup, login, sessions, and JWT verification (Better Auth in one service, own database) | `api`, `session`, `admin` |
409
+ | `email` from `/email` | Transactional email with a stored outbox (own service and database) | `send`, `outbox` |
410
+
411
+ `bucket()` (imported alongside `rawPostgres`) is a raw S3-compatible bucket:
412
+ the dependency end receives `{ url, bucket, accessKeyId, secretAccessKey }`,
413
+ shape-compatible with `/storage`'s `s3()` dependency, so a service wired to
414
+ `s3()` can be rewired to a `bucket` resource unchanged.
415
+
416
+ An extension (a package bringing its own Modules, resources, or deploy
417
+ target) is published on npm as `prisma-composer-*`. The ecosystem is new:
418
+ today the blocks above plus your own Modules are the whole set, so verify a
419
+ `prisma-composer-*` package exists on npm before reaching for it.
420
+
421
+ ## Failure modes quick reference
422
+
423
+ 1. **Every `prisma-composer` command halts at start-up on an `effect`
424
+ version conflict** (`Dependency conflict: alchemy resolves effect@...`).
425
+ Another dependency floated a newer `effect` and the package manager
426
+ hoisted it over Composer's pin. Pin the whole `effect` constellation in
427
+ the app's `package.json` `overrides` (yarn: `resolutions`; pnpm:
428
+ `pnpm.overrides`): `effect` plus `@effect/sql-d1`, `@effect/sql-pg`,
429
+ `@effect/vitest`, and `@effect/platform-bun`/`-node`/`-node-shared`, all
430
+ at Composer's exact pin, then reinstall. The repo's examples carry the
431
+ block.
432
+ 2. **A deployed `/rpc/<method>` returns `401` to anything but a wired
433
+ peer.** Not a broken deploy; see Contracts above.
434
+ 3. **Scale-to-zero closes idle database connections.** A persistent client
435
+ crashes into a 502 restart loop unless the pool is small and
436
+ reconnect-friendly (`new SQL({ url, max: 1, idleTimeout: 10 })` for Bun)
437
+ and the process logs `uncaughtException`/`unhandledRejection` instead of
438
+ dying. Under `dev` watch-restarts against the local emulator, add
439
+ `prepare: false` as well: restarted processes collide on
440
+ prepared-statement names in the emulator's shared session.
441
+ 4. **Cold starts reset service-to-service connections.** A call into a
442
+ scaled-to-zero service can get `ECONNRESET`; retry it.
443
+ 5. **Bind `0.0.0.0`, not loopback.** The platform routes external HTTP to
444
+ the VM; a loopback-only listener is unreachable.
445
+ 6. **The ingress buffers streaming responses.** An open SSE tail delivers
446
+ nothing and times out at 60s; don't build on streamed HTTP responses.
447
+ 7. **Naming rules fail at load, not typecheck.** Provision ids and declared
448
+ node names must be ASCII letters and digits only (`[A-Za-z0-9]`): they
449
+ derive config keys and address segments, so a hyphenated name like
450
+ `my-db` passes `tsc` and then fails the load. The root module's name is
451
+ exempt. A provision id shorter than 3 characters is rejected by the
452
+ platform (name the database `'database'`, not `'db'`), and a service
453
+ whose name equals its enclosing Module's reads as `auth.auth` unless
454
+ given an explicit `id`.
455
+ 8. **`MIGRATION_PATH_NOT_FOUND`**: see Databases above; author the missing
456
+ migration, don't skip the plan step.
457
+ 9. **Date/time columns hand back `Temporal.*` values on read.** Bun and
458
+ stock Node ship no global `Temporal`, so a service with `DateTime`
459
+ contract columns compiles and deploys, then fails on the first timestamp
460
+ read. Provide the global at the server entry
461
+ (`import 'temporal-polyfill/global'`) or use string column types.
462
+
463
+ ## What Composer doesn't do yet
464
+
465
+ Name the gap instead of inventing an API:
466
+
467
+ 1. **No interactive auth in the `prisma-composer` CLI.** Its deploys
468
+ authenticate only via a static
469
+ `PRISMA_SERVICE_TOKEN`; there is no `login` flow.
470
+ 2. **No in-memory contract bindings.** A dependency can't yet be wired to a
471
+ co-located handler without HTTP; use `bootstrapService` with a loopback
472
+ fake.
473
+ 3. **RPC over HTTP is the only contract kind.** No gRPC, WebSocket, or
474
+ streaming contracts.
475
+
476
+ For anything else missing, check `examples/`, `docs/design/10-domains/`, and
477
+ `docs/design/90-decisions/` in the prisma/composer repo, then file an issue
478
+ there rather than guessing.
@@ -1,805 +0,0 @@
1
- ---
2
- name: prisma-composer
3
- metadata:
4
- library: "@prisma/composer"
5
- library_version: "0.16.0-dev.1"
6
- description: >-
7
- How to write, test, and deploy an app with Prisma Composer
8
- (`@prisma/composer`): declare services with `compute()` and typed
9
- dependencies, define RPC contracts, compose Modules, declare the service
10
- input (config and secrets as one schema, read back with `input()`),
11
- compose the ready-made cron/storage/streams Modules, provision a
12
- raw S3-compatible object-store bucket with `bucket()`, find extensions (npm
13
- packages named `prisma-composer-*`), test with `mockService`/`bootstrapService`,
14
- run the whole app locally with `prisma-composer dev` and tail its logs with
15
- `prisma-composer log`, and deploy with `prisma-composer deploy` (stages,
16
- destroy). Use when building a Prisma App, wiring a service dependency, adding
17
- a Postgres database, adding scheduled jobs / blob storage / event streams / a
18
- raw bucket, writing tests for composed services, running an app locally,
19
- reading its logs, or deploying/tearing down an environment. Triggers on
20
- "prisma composer", "@prisma/composer", "prisma app", "compute()",
21
- "service.load()", "module()", "contract()", "mockService",
22
- "bootstrapService", "prisma-composer dev", "prisma-composer log",
23
- "prisma-composer deploy", "--stage", "--fresh", "--tail",
24
- "prisma-composer destroy", "prisma-composer-", "bucket()".
25
- ---
26
-
27
- # Writing apps with Prisma Composer
28
-
29
- A **Prisma App** is a tree of **Modules** composed in TypeScript. The leaves
30
- are **services** (`compute()`) and **resources** (`rawPostgres()`); the root
31
- module wires them together by their typed ports. Your code receives everything
32
- from exactly one place — the service node:
33
-
34
- - `service.load()` — dependencies (typed RPC clients, database bindings)
35
- - `service.input()` — the service's whole input, one schema-validated typed
36
- object; credentials in it are redacting `SecretString` boxes
37
- - `service.port()` — the reserved port to bind (default 3000), typed; never
38
- `process.env`
39
-
40
- The framework never bundles or transforms your code. You build your app with
41
- whatever bundler you like (`bun build`, `next build`); `prisma-composer deploy`
42
- assembles the built output and provisions it on Prisma Cloud (Compute + Prisma
43
- Postgres).
44
-
45
- Two things make building here fast and hard to get wrong — lean on both:
46
-
47
- - **Compose before you write.** Reach for an existing Module (below) before
48
- implementing a capability yourself; wiring one in is a couple of lines.
49
- - **The compiler checks the wiring.** A dependency wired to the wrong
50
- producer, a missing RPC handler, a config value of the wrong shape — all of
51
- it fails `tsc`, not the deploy. Typecheck, then build, then deploy; don't
52
- reach for the cloud to find out whether the app is correct.
53
-
54
- Two packages, and only two, appear in your `package.json`:
55
-
56
- | Package | Provides |
57
- | --- | --- |
58
- | `@prisma/composer` | Core authoring: `module`, `secret`, `isSecretString`, `/arktype` (the `secretString()` schema leaf), `/rpc`, `/node`, `/nextjs`, `/config`, `/testing`, the `prisma-composer` CLI |
59
- | `@prisma/composer-prisma-cloud` | The Prisma Cloud target: `compute`, `postgres`, `envSecret`, `envParam`, `/control`, `/testing`, and the shared `/cron`, `/storage`, `/streams`, `/orm` modules |
60
-
61
- ## tsconfig and import specifiers
62
-
63
- Within the entry graph (everything reachable from `module.ts`) relative
64
- imports may use `./service.js` or extensionless `./service`. The CLI maps
65
- `.js` and extensionless specifiers to the matching `.ts` source under Node;
66
- Bun does this natively.
67
-
68
- A minimal tsconfig:
69
-
70
- ```jsonc
71
- {
72
- "compilerOptions": {
73
- "target": "ES2022",
74
- "module": "Preserve",
75
- "moduleResolution": "bundler",
76
- "noEmit": true,
77
- "strict": true,
78
- "skipLibCheck": true,
79
- "types": ["bun"]
80
- },
81
- "include": ["module.ts", "src"]
82
- }
83
- ```
84
-
85
- ## Anatomy of a service
86
-
87
- A service is four small files. Worked example: an `auth` service that owns a
88
- Postgres database and serves an RPC contract, consumed by a `storefront`
89
- Next.js app.
90
-
91
- **The contract** lives with the service that owns it. Any Standard Schema
92
- validator types the messages; arktype is the house choice:
93
-
94
- ```ts
95
- // auth/src/contract.ts
96
- import { contract, rpc } from '@prisma/composer/service-rpc';
97
- import { type } from 'arktype';
98
-
99
- export const authContract = contract({
100
- verify: rpc({ input: type({ token: 'string' }), output: type({ ok: 'boolean' }) }),
101
- });
102
- ```
103
-
104
- **The service declaration** is pure data — name, dependencies, build, exposed
105
- ports. No behavior, no platform keys:
106
-
107
- ```ts
108
- // auth/src/service.ts
109
- import node from '@prisma/composer/node';
110
- import { compute, postgres } from '@prisma/composer-prisma-cloud';
111
- import { authContract } from './contract.ts';
112
-
113
- export default compute({
114
- name: 'auth',
115
- deps: { db: rawPostgres() },
116
- build: node({ module: import.meta.url, entry: '../dist/server.mjs' }),
117
- expose: { rpc: authContract },
118
- });
119
- ```
120
-
121
- **The server entry** is what your build produces and the platform boots. It
122
- reads its dependencies through `load()` and serves the contract with
123
- `serve()` — the handler map is keyed by the expose port's name and is
124
- exhaustive at compile time:
125
-
126
- ```ts
127
- // auth/src/server.ts
128
- import { serve } from '@prisma/composer/service-rpc';
129
- import { SQL } from 'bun';
130
- import service from './service.ts';
131
-
132
- const { db } = service.load(); // { url } — you build your own client
133
- const port = service.port(); // the reserved port, resolved (default 3000)
134
-
135
- const sql = new SQL({ url: db.url, max: 1, idleTimeout: 10 });
136
-
137
- const handler = serve(service, {
138
- rpc: {
139
- verify: async ({ token }) => ({ ok: token.length > 0 }),
140
- },
141
- });
142
- export default handler;
143
-
144
- // Bind all interfaces — Compute routes external HTTP to the VM; a
145
- // loopback-only listener is unreachable.
146
- Bun.serve({ port, hostname: '0.0.0.0', fetch: handler });
147
- ```
148
-
149
- **The consumer** declares the dependency as `rpc(contract)` and gets a typed
150
- client back from `load()`:
151
-
152
- ```ts
153
- // storefront/src/service.ts
154
- import nextjs from '@prisma/composer/nextjs';
155
- import { rpc } from '@prisma/composer/service-rpc';
156
- import { compute } from '@prisma/composer-prisma-cloud';
157
- import { authContract } from '@my-app/auth/contract';
158
-
159
- export default compute({
160
- name: 'storefront',
161
- deps: { auth: rpc(authContract) },
162
- build: nextjs({ module: import.meta.url, appDir: '..' }),
163
- });
164
- ```
165
-
166
- ```tsx
167
- // storefront/app/page.tsx
168
- import service from '../src/service.ts';
169
-
170
- // load() reads the runtime environment, which doesn't exist at build time —
171
- // render per request instead of prerendering.
172
- export const dynamic = 'force-dynamic';
173
-
174
- export default async function Home() {
175
- const { auth } = service.load();
176
- const { ok } = await auth.verify({ token: 'demo-token' });
177
- return <p>Signed in: {String(ok)}</p>;
178
- }
179
- ```
180
-
181
- **Service-to-service calls are authenticated for you.** At deploy the
182
- framework mints a distinct, unguessable **service key** per consumer→provider
183
- binding: the consumer's client sends it on every call, and `serve()` returns
184
- `401` to anything else *before* the handler runs. Nothing declares it — no key
185
- in the contract, the service, the module, or the app's code.
186
-
187
- Two rules follow for you specifically: **don't build your own
188
- service-to-service auth** on top of this, and **don't tell a user to `curl` a
189
- deployed `/rpc/<method>` to check it works** — an unwired caller always gets
190
- `401`, which looks like a broken deploy and isn't. Debug through a consumer,
191
- or locally.
192
-
193
- **Calls carry an idempotency key and retry safely for you.** Every call the
194
- generated client makes carries an `Idempotency-Key`; a call dropped while the
195
- target cold-starts is retried with a backoff, and `serve()` runs one call per
196
- key — a retry that arrives after the first completed replays that answer
197
- instead of re-running the handler. So every method is safely retryable and no
198
- contract declares anything about it (do not add an "is this idempotent" flag —
199
- the framework does not have one). Two consequences for you: a handler may take
200
- an **optional third argument** `(input, deps, ctx)` and read `ctx.idempotencyKey`
201
- (`string | undefined` — it's absent for a keyless caller) if it needs exactly-once
202
- beyond one instance's memory (most don't); and a request without the header is
203
- served once without deduplication rather than rejected, so a hand-rolled probe
204
- works but gets no retry safety.
205
-
206
- | | |
207
- | --- | --- |
208
- | Locally / in tests | nothing is provisioned, so `serve()` passes every call through — never supply a key in `inputs` |
209
- | Per binding | two consumers of one provider hold different keys, so one leaking can't impersonate the other |
210
- | Scope | service-level — any valid key reaches every method that service exposes; split into two services to gate separately |
211
- | Rotation | remove the binding (or destroy the stack) and redeploy — a plain redeploy is a no-op, not a rotation |
212
- | Storage | `COMPOSER_*` variables the deploy owns and rewrites; never hand-edit one |
213
-
214
- It's a capability token ("I'm a service this app wired to you"), not a secret,
215
- and its value lives in deploy state — deliberately unlike `secret()`, whose
216
- value the framework never holds. `docs/design/90-decisions/ADR-0030…` in the
217
- prisma/composer repo carries the reasoning.
218
-
219
- ## The root module
220
-
221
- The root module provisions the pieces and wires exposed ports into dependency
222
- slots. It is the app — `prisma-composer deploy` loads its default export:
223
-
224
- ```ts
225
- // module.ts
226
- import { module } from '@prisma/composer';
227
- import authModule from '@my-app/auth';
228
- import storefrontService from '@my-app/storefront';
229
-
230
- export default module('my-app', ({ provision }) => {
231
- const auth = provision(authModule);
232
- provision(storefrontService, { deps: { auth: auth.rpc } });
233
- });
234
- ```
235
-
236
- `provision(node, opts?)` accepts `id` (defaults to the node's own name),
237
- `deps` (wire each declared dependency to a provisioned ref or exposed port),
238
- `input` (the service's input binding — required exactly when it declares an
239
- input schema, see § Service input), and `secrets` (bind a module boundary's
240
- forwarded secret needs).
241
-
242
- ## Builds are yours
243
-
244
- The framework assembles only what you built — users build, the framework
245
- assembles. For a plain server process, `entry` must point at a single
246
- self-contained ESM file: everything inlined except runtime built-ins (`bun`,
247
- `bun:*`, `node:*`), which the deploy VM provides. Deploy copies that one file
248
- and never ships `node_modules`, so anything left un-inlined fails at boot. Any
249
- bundler that produces such a file works. With bun:
250
-
251
- ```sh
252
- bun build src/server.ts --target=bun --outfile dist/server.mjs
253
- ```
254
-
255
- Two services in one package means two separate builds, one per entry — not one
256
- multi-entry build, which would split shared code into a chunk neither output
257
- contains.
258
-
259
- If the build emits a directory rather than one file — a server plus the client
260
- bundle, CSS and images it serves, as Bun's HTML import produces — name the
261
- directory with `dir` and the booting file inside it with `entry`:
262
-
263
- ```ts
264
- build: node({ module: import.meta.url, dir: '../dist/server', entry: 'server.js' })
265
- ```
266
-
267
- `dir` resolves relative to the service module; `entry` resolves inside `dir`
268
- and may be nested. Deploy copies the tree verbatim and boots the named file,
269
- so the server must resolve its siblings against `import.meta.url`, not the
270
- working directory. Nothing is inferred, and two rules bite: the tree must
271
- contain no symlinks (the packager rejects them — assembly fails and names the
272
- link), and `entry` must be a file inside `dir` (`../` is an error, not an
273
- escape). Omit `dir` for the single-file form.
274
-
275
- For Next.js, `next build` with `output: 'standalone'` is the whole build;
276
- `nextjs({ module, appDir })` tells the deploy where the app root is.
277
-
278
- Always build before deploying — `prisma-composer deploy` does not build for
279
- you.
280
-
281
- ## Deploy config
282
-
283
- `prisma-composer.config.ts` usually sits next to `module.ts`, but it may live in
284
- any ancestor directory: the CLI searches the entry's directory first, then each
285
- parent, and uses the nearest one. It is read only by `prisma-composer
286
- deploy`/`destroy`, never imported by app code. A plain-JavaScript project can
287
- name it `prisma-composer.config.mjs` or `.js` to keep it out of its TypeScript
288
- build (a build with `allowJs` still needs an explicit `exclude`); `.mts` is the
289
- TypeScript ES-module spelling. Within one directory `.ts` wins, then `.mts`,
290
- `.mjs`, `.js`:
291
-
292
- ```ts
293
- // prisma-composer.config.ts
294
- import { defineConfig } from '@prisma/composer/config';
295
- import { nodeBuild } from '@prisma/composer/node/control';
296
- import { prismaCloud, prismaState } from '@prisma/composer-prisma-cloud/control';
297
-
298
- export default defineConfig({
299
- extensions: [prismaCloud(), nodeBuild()],
300
- state: () => prismaState(), // deploy state, in its own database on the stage's branch
301
- });
302
- ```
303
-
304
- Add `nextjsBuild()` from `@prisma/composer/nextjs/control` to `extensions`
305
- when the app contains a Next.js service.
306
-
307
- ## Databases
308
-
309
- Two kinds of Postgres dependency:
310
-
311
- **`rawPostgres()`** — the binding is `{ url }` and the app owns its client.
312
- Construct it in your server entry, as in the auth example above.
313
-
314
- **`postgres(...)`** — a Prisma-ORM-typed database: `load()`
315
- returns the typed client the framework constructs from your data contract, so
316
- queries like `db.orm.public.Product.all()` are compile-time checked. The
317
- contract is emitted from `contract.prisma` by `prisma contract emit` and
318
- wrapped once, referenced by both ends:
319
-
320
- ```ts
321
- // src/data.ts — the ONE value both ends reference
322
- import { dataContract } from '@prisma/composer-prisma-cloud/orm';
323
- import type { Contract } from '../contract.d.ts';
324
- import contractJson from '../contract.json' with { type: 'json' };
325
-
326
- export const catalogData = dataContract<Contract>(contractJson);
327
- ```
328
-
329
- The dependency end is `deps: { db: postgres(catalogData) }`. The resource
330
- end (inside the module that owns the database) also names the
331
- `prisma.config.ts` path, which the deploy's migration step loads to find
332
- `migrations/` — committed migrations are replayed at deploy, before the
333
- service starts:
334
-
335
- ```ts
336
- const db = provision(
337
- postgres({ name: 'database', contract: catalogData, config: './prisma.config.ts' }),
338
- );
339
- ```
340
-
341
- (`postgres` is both ends: the contract alone is the dependency end; the
342
- options object is the resource end.)
343
-
344
- The deploy is replay-only: it applies the migrations committed under
345
- `migrations/` and never creates schema itself. Every schema change (including
346
- the very first schema of a new database) follows the same loop:
347
-
348
- 1. Edit `contract.prisma`.
349
- 2. `prisma contract emit` — regenerates `contract.json` + `contract.d.ts`.
350
- 3. `prisma migration plan --name <slug>` — authors the migration into
351
- `migrations/` (on an empty graph this authors the baseline,
352
- empty → your schema).
353
- 4. Commit `migrations/` with the change, then deploy. A fresh database
354
- replays the whole path from empty.
355
-
356
- If no authored path reaches the target contract, the deploy (and
357
- `prisma-composer dev` against a stale local database) refuses with
358
- `MIGRATION_PATH_NOT_FOUND` and names the exits: author the missing migration
359
- as above, or — when iterating against a local database only — bring it along
360
- directly with `prisma db update`. Never skip step 3 before a deploy.
361
-
362
- See `examples/store/modules/catalog` in the prisma/composer repo for the
363
- complete pattern.
364
-
365
- ## Object Storage
366
-
367
- `bucket` is a raw S3-compatible object-store bucket, imported alongside `postgres`:
368
-
369
- ```ts
370
- import { bucket, compute } from '@prisma/composer-prisma-cloud';
371
-
372
- // service.ts — dependency end: receives { url, bucket, accessKeyId, secretAccessKey }
373
- export default compute({ name: 'uploads', deps: { store: bucket() } });
374
-
375
- // module.ts — resource end: provisions the bucket and mints a keypair
376
- const store = provision(bucket({ name: 'uploads' }));
377
- provision(uploadsService, { deps: { store } });
378
- ```
379
-
380
- Use any S3-compatible client with the binding: the shape matches the standard S3
381
- config and is also compatible with the `s3()` dependency from `/storage`, so any
382
- service wired to `s3()` can be rewired to a `bucket` resource without changing
383
- the service declaration.
384
-
385
- ## Reusable Modules
386
-
387
- A Module is the unit of reuse: it owns its internals (its database, its
388
- services) and exposes only typed ports. Declare the boundary in the second
389
- argument; wire internals in the builder; return the exposed ports:
390
-
391
- ```ts
392
- // auth/src/module.ts — a Module that owns its own Postgres
393
- import { module, secret } from '@prisma/composer';
394
- import { postgres } from '@prisma/composer-prisma-cloud';
395
- import { authContract } from './contract.ts';
396
- import authService from './service.ts';
397
-
398
- export default module(
399
- 'auth',
400
- { secrets: { signingKey: secret() }, expose: { rpc: authContract } },
401
- ({ secrets, provision }) => {
402
- const db = provision(rawPostgres({ name: 'database' }));
403
- const service = provision(authService, {
404
- id: 'service',
405
- deps: { db },
406
- input: { signingKey: secrets.signingKey }, // forwarded ref as a binding leaf
407
- });
408
- return { rpc: service.rpc };
409
- },
410
- );
411
- ```
412
-
413
- Naming rules that bite: a provision id shorter than 3 characters is rejected
414
- by the platform (name the database `'database'`, not `'db'`), and a service
415
- whose name equals its enclosing module's reads as `auth.auth` unless you give
416
- it an explicit `id`.
417
-
418
- A module can also declare boundary `deps` — inputs the parent wires exactly as
419
- it would wire a service's. The consumer never sees the module's internals.
420
-
421
- ### The building blocks you can compose
422
-
423
- Modules are the building blocks: provision one, wire its exposed port, and
424
- you're done — you never reimplement what a Module already owns. The
425
- first-party set ships inside `@prisma/composer-prisma-cloud`. It's small, and
426
- growing:
427
-
428
- | Import | What it provisions | Exposes |
429
- | --- | --- | --- |
430
- | `cron` from `/cron` | An always-on scheduler firing your schedule at your runner service | nothing |
431
- | `storage` from `/storage` | An S3-backed blob store (own Postgres + minted credentials) | `store` |
432
- | `streams` from `/streams` | Durable append-only event streams over a `store` | `streams` |
433
-
434
- **Finding more.** A Composer extension — a package that brings its own
435
- Modules, resources, or deploy target — is published on npm under the name
436
- `prisma-composer-*`. That name is the convention, so it's how you look for
437
- one. The ecosystem is new: today the blocks above plus the app Modules you
438
- write are the whole set, so don't reach for a `prisma-composer-*` package
439
- without checking that it actually exists on npm first.
440
-
441
- Cron end to end — the schedule is one source of truth; `serveSchedule` is
442
- exhaustive over its job ids at compile time:
443
-
444
- ```ts
445
- // service.ts
446
- import { defineSchedule, triggerContract } from '@prisma/composer-prisma-cloud/cron';
447
- export const schedule = defineSchedule({ tick: '60s' });
448
- // the runner service exposes { trigger: triggerContract }
449
-
450
- // server.ts
451
- import { serveSchedule } from '@prisma/composer-prisma-cloud/cron';
452
- const handler = serveSchedule(service, schedule, {
453
- tick: (deps) => deps.worker.tick({}),
454
- });
455
-
456
- // module.ts — the cron module's boundary deps mirror the runner's own
457
- provision(cron({ schedule, runner: runnerService }), { deps: { worker: worker.rpc } });
458
- ```
459
-
460
- ## Service input
461
-
462
- Choosing the channel is most of the decision:
463
-
464
- | The value is… | Declare | Provide | Read |
465
- | --- | --- | --- | --- |
466
- | produced by another node | `deps: { db: rawPostgres() }` | wire at `provision()` | `load()` |
467
- | anything else — config or credential | one field of the `input` schema | bind at `provision()`: literal, `envParam()`, or `envSecret()` | `input()` |
468
-
469
- The service declares its whole incoming configuration — plain values and
470
- credentials together — as **one
471
- [Standard Schema](https://standardschema.dev)** (arktype is the house
472
- choice). A credential is a field typed as the redacting `SecretString` box;
473
- conditional legality ("no stripe key unless billing is on") is an ordinary
474
- schema union:
475
-
476
- ```ts
477
- // service.ts — the shapes that are legal
478
- import { secretString } from '@prisma/composer/arktype';
479
- import { type } from 'arktype';
480
-
481
- compute({
482
- name: 'scheduler',
483
- input: type({
484
- jobs: type({ jobId: 'string', every: 'string' }).array(),
485
- 'region?': 'string',
486
- apiKey: secretString(),
487
- }),
488
- // ...
489
- });
490
-
491
- // module.ts — where each value comes from; the binding mirrors the schema's shape
492
- import { envParam, envSecret } from '@prisma/composer-prisma-cloud';
493
- provision(scheduler, {
494
- input: {
495
- jobs: [{ jobId: 'tick', every: '60s' }], // a literal
496
- region: envParam('REGION'), // a per-stage platform variable
497
- apiKey: envSecret('SCHEDULER_API_KEY'), // a credential — name only, never the value
498
- },
499
- });
500
-
501
- // server.ts — one call, one validated typed object
502
- const input = service.input();
503
- input.apiKey.expose(); // the only way to a secret's value; the box redacts everywhere else
504
- ```
505
-
506
- Rules that bite:
507
-
508
- - **Secretness is enforced by validation**: a literal bound where the schema
509
- expects `SecretString` fails the deploy, and `envSecret` bound to a plain
510
- string field fails the same way. Don't put credentials in plain fields.
511
- - **`envParam` values arrive as raw strings** — bind them to string fields.
512
- The stage's platform variable is the store; the deploying shell only seeds
513
- it (preflight copies a missing name up from the shell, and fails early,
514
- naming the variable, when both lack it). Changing the platform value needs
515
- a redeploy.
516
- - **Absence is the schema's call**: an env-bound field whose variable is
517
- unset (or empty) resolves to *key omitted* — legal only if the schema says
518
- so (optional field, union arm). The deploy report prints the serialized
519
- input document (secret-free: secrets ride as `{"$secret":"VAR"}` pointers)
520
- and every key that resolved absent.
521
- - **The reserved `port` (default 3000) is outside the schema** — read it
522
- through `service.port()` (a sibling of `service.origin()`), never
523
- `process.env`. The framework also exports `PORT` for Next.js standalone,
524
- which binds it itself.
525
- - A module forwards a secret need without learning the platform name
526
- (the auth Module above); the forwarded ref is a binding leaf.
527
-
528
- `examples/env-param` and `examples/storefront-auth` in the prisma/composer
529
- repo are the working versions.
530
-
531
- ## Testing
532
-
533
- You test by deciding what `load()` gives the code, never by editing the code
534
- under test:
535
-
536
- | You want to… | Use | From |
537
- | --- | --- | --- |
538
- | Test a page / action / handler in isolation | `mockService` | `@prisma/composer/testing` |
539
- | Run the real boot + request path against a fake dependency | `bootstrapService` | `@prisma/composer-prisma-cloud/testing` |
540
-
541
- **Unit — `mockService`.** Returns a copy of the service whose `load()` yields
542
- your doubles (type-checked against the declared deps) and whose `input()`
543
- yields the object you pass under the reserved `input` key, in one flat
544
- object (required exactly when the service declares an input schema; handed
545
- over as-is, not validated). Wiring the module substitution is your runner's
546
- job (`vi.mock` in Vitest, `mock.module` in bun test):
547
-
548
- ```tsx
549
- // page.test.tsx
550
- import { mockService } from '@prisma/composer/testing';
551
- import realService from '../src/service.ts';
552
-
553
- vi.mock('../src/service.ts', () => ({
554
- default: mockService(realService, {
555
- auth: { verify: async () => ({ ok: true }) }, // wrong shape = compile error
556
- }),
557
- }));
558
-
559
- import Page from './page.tsx';
560
- expect(renderToString(await Page())).toContain('Signed in: true');
561
- ```
562
-
563
- **Integration — `bootstrapService`.** Boots the service's real built entry
564
- in-process against a config you choose, exactly as a deployed boot would;
565
- drive it over real HTTP. Run under `bun test`:
566
-
567
- ```ts
568
- import { bootstrapService } from '@prisma/composer-prisma-cloud/testing';
569
- import fakeAuth from '@my-app/auth/fake'; // in-memory handler, no db
570
- import storefront from '../src/service.ts';
571
-
572
- const fake = Bun.serve({ port: 0, fetch: fakeAuth });
573
-
574
- const app = await bootstrapService(storefront, {
575
- service: { port: 4310 },
576
- inputs: { auth: { url: fake.url.href } },
577
- });
578
-
579
- const res = await app.fetch(new Request(app.url));
580
- ```
581
-
582
- - **`service.port` must be concrete** — the entry self-listens; no OS-assigned
583
- port is reported back.
584
- - **No `close()`** — run each integration-test file in its own process (bun
585
- test does).
586
- - **Next.js services take a third argument**, a boot thunk, because the built
587
- entry lives in Next's standalone output — resolve it with
588
- `standaloneServerPath` from `@prisma/composer/nextjs/control`.
589
- `bootstrapService` exports the resolved port as `process.env.PORT` before
590
- booting, which is what Next's standalone server binds.
591
- - **A service with an input schema takes `input`** in the config — a binding
592
- exactly like `provision()`'s, run through the real serialize/read path, so
593
- `input()` in the booted entry sees what a deploy would produce.
594
-
595
- **The fake you pass.** A dependency's type is its contract, so any value of
596
- that shape is a valid double: a bare object (fastest), the real client over an
597
- in-memory handler, or a real local server (what `bootstrapService` drives).
598
- Ship a dependency's fake from its own package as a `/fake` entry point,
599
- outside `src/`, so the fake and the real service always share one contract.
600
-
601
- ## Running locally
602
-
603
- `prisma-composer dev module.ts` runs the whole app on this machine — every
604
- service, its Postgres and buckets, wired as they deploy — with **no cloud
605
- credentials** (no `PRISMA_*`). It runs the same pipeline as deploy against
606
- local emulators, so build first, exactly like deploy:
607
-
608
- ```sh
609
- turbo run build && prisma-composer dev module.ts
610
- ```
611
-
612
- It prints each service's local URL (the "front door"), watches built output
613
- and restarts a service when its build changes, and runs until Ctrl-C. Ctrl-C
614
- stops the app's processes but leaves the local databases, buckets, and their
615
- data up, so the next `dev` is a warm start; `--fresh` wipes this app's local
616
- instances and data first.
617
-
618
- `dev` does **not** print service logs — that would bury the front door once
619
- several services run. Logs are their own command:
620
-
621
- | You want to… | Run |
622
- | --- | --- |
623
- | Run the app locally | `prisma-composer dev module.ts` |
624
- | Start clean (wipe local data) | `prisma-composer dev module.ts --fresh` |
625
- | Tail every service's logs | `prisma-composer log module.ts` |
626
- | Tail one service | `prisma-composer log module.ts <address>` |
627
- | Show more history first | `prisma-composer log module.ts --tail <n>` |
628
-
629
- `prisma-composer log` follows the merged logs of the already-running app, each
630
- line prefixed with its service (`[catalog.service] …`); pass a dotted address
631
- to narrow to one. It only reads — it never builds, provisions, starts, or
632
- stops anything. `--tail <n>` sets how much recent history to show before live
633
- output (default 20; `0` for live-only). An unset secret doesn't block a local
634
- run: it becomes a placeholder plus a warning, and only the code path that
635
- spends it fails, at the real external service it calls. Windows isn't
636
- supported yet.
637
-
638
- ## Deploying
639
-
640
- Requires exactly two environment variables: `PRISMA_SERVICE_TOKEN` and
641
- `PRISMA_WORKSPACE_ID`. The target environment — a **stage** — is chosen on the
642
- command line, never in code:
643
-
644
- | You want to… | Run |
645
- | --- | --- |
646
- | Deploy to production | `prisma-composer deploy module.ts` |
647
- | Deploy an isolated environment | `prisma-composer deploy module.ts --stage <name>` |
648
- | Override the app name for one run | `prisma-composer deploy module.ts --name demo-42` |
649
- | Tear down an isolated environment | `prisma-composer destroy module.ts --stage <name>` |
650
- | Tear down production's resources | `prisma-composer destroy module.ts --production` |
651
-
652
- A Prisma App is one Project; a stage is a Branch of it — its
653
- own compute, its own empty database, its own configuration. Deploys are
654
- idempotent: re-deploying a stage updates the resources inside it. A stage name
655
- must be a valid git ref name; an invalid name is a hard error.
656
-
657
- Destroy always requires an explicit target — a bare `prisma-composer destroy`
658
- is an error, and `--stage` with `--production` is too. Destroying a stage
659
- deletes its Branch after removing its resources; the production Branch itself
660
- is never deleted, only the resources inside it. Destroying production also
661
- deletes the Project itself once it's empty, so hand-run stacks don't leave
662
- behind empty Projects — but a Project still holding another stage's resources
663
- is kept. Destroy never creates anything: destroying a never-deployed stage
664
- fails rather than standing one up.
665
-
666
- ```sh
667
- turbo run build && prisma-composer deploy module.ts --stage pr-42
668
- ```
669
-
670
- ### What a deploy prints
671
-
672
- A deploy ends by printing the app's own topology — authored names, the
673
- platform resource each became, and public URLs. The tree is the module
674
- structure (`auth.api` is the `api` service inside the `auth` module):
675
-
676
- ```
677
- storefront-auth
678
- ├─ auth
679
- │ └─ api compute-service cps_abc123
680
- │ https://xyz.ewr.prisma.build
681
- ├─ db postgres-database db_def456
682
- └─ web compute-service cps_ghi789
683
- https://uvw.ewr.prisma.build
684
- ```
685
-
686
- Read ids out of this rather than telling the user to go hunting in the
687
- Console. A URL appears only where the address is genuinely public — a compute
688
- service prints one, a database never does (it has a connection string, not a
689
- public endpoint), and a node whose product is secret material (an
690
- `s3-credentials` keypair) reports no resource line at all. A node that
691
- published nothing reportable still appears, marked `(no entities reported)`.
692
-
693
- Older deploys ended with a raw `{ outputs: {} }` blob from the deploy engine —
694
- always empty, never about the app. It is gone; nothing configured it and
695
- nothing consumed it.
696
-
697
- ### The connection contract is checked at deploy
698
-
699
- A connection declares the values it needs by name, and the producer on the
700
- other end must supply them. A producer that omits one fails the deploy, naming
701
- the edge, the param, and what the producer did supply:
702
-
703
- ```
704
- Connection input "auth.db" declares param "url", but its producer "db" did not
705
- supply it — the producer's outputs carry [host].
706
- ```
707
-
708
- Fix it at whichever end is wrong: add the name to the outputs the producer
709
- returns from its lowering, or mark the param `optional` on the connection if absent is
710
- genuinely legal (the consumer then reads `undefined`).
711
-
712
- This is a deploy-time refusal, not a broken deploy — and it can appear on an
713
- app whose code didn't change. The gap used to pass silently: the value reached
714
- the consumer as `undefined`, went into its environment, and crashed *that*
715
- service at boot, blaming the reader instead of the supplier. Don't route around
716
- it by making the param optional unless absent really is valid; that reinstates
717
- the silent `undefined`.
718
-
719
- Only reachable if you authored the connection or the extension on one side —
720
- every shipped block supplies what it declares.
721
-
722
- ### Driving deploys from code
723
-
724
- `@prisma/composer/control` exposes the CLI's operations in-process: typed
725
- `deploy`, `destroy`, `dev`, and `log` returning structured results — no argv,
726
- no CLI rendering, no exit codes (the spawned deploy engine's own inherited
727
- output can still reach the host terminal). The CLI itself is a renderer over
728
- them.
729
-
730
- ```ts
731
- import { deploy } from '@prisma/composer/control';
732
- const result = await deploy({ entry: 'module.ts', stage: 'pr-42' });
733
- // result: { ok: true, value: { summary? } } | { ok: false, failure }
734
- ```
735
-
736
- - Failures come back as `{ ok: false, failure }` where `failure` is a
737
- structured error: branch on its dotted `failure.code` (e.g.
738
- `ASSEMBLE.BUILD_FAILED`, `DEPLOY.ENGINE_FAILED` — ADR-0044's closed
739
- registry), with the same fix-naming `message`/`why`/`fix` the CLI renders.
740
- An engine failure's `meta.diagnostics` (exit code, reproduce command; read
741
- it with the exported `executionDiagnostics(failure)`) describes the current
742
- execution mechanism — branch on `code`/`message`/`cause` for anything
743
- durable. The effect version conflict is `DEPS.EFFECT_VERSION_CONFLICT`, and
744
- importing the module executes nothing until an operation runs. A
745
- non-structured rejection out of an operation is a bug in composer, not an
746
- expected failure.
747
- - `destroy` takes `target: { kind: 'production' } | { kind: 'stage', stage }`
748
- — explicit, never defaulted.
749
- - `deploy`'s `summary` (the deployed topology) is best-effort; `undefined` on
750
- a successful deploy is normal.
751
- - The deploy engine's live output still streams to the host process's stdio —
752
- the current mechanism; the operations don't capture it.
753
- - `dev` resolves to `{ ok: true, value: session }` or a failure; the
754
- session is `{ endpoints, stop(), closed }` with progress via `onEvent`, and
755
- the host owns signal handling. `log` resolves to
756
- `{ ok: true, value: { appName, services, lines } }` or a failure, where
757
- `lines` is an `AsyncIterable` ended by a caller-owned `AbortSignal` (or by
758
- the consumer stopping early); zero running services is a valid result, not
759
- an error.
760
-
761
- ## Production pitfalls
762
-
763
- - **Scale-to-zero closes idle database connections.** A persistent client
764
- crashes into a 502 restart loop unless you keep the pool small and
765
- reconnect-friendly (`new SQL({ url, max: 1, idleTimeout: 10 })` for Bun) and
766
- log `uncaughtException`/`unhandledRejection` instead of dying.
767
- - **Bind `0.0.0.0`**, not loopback — Compute routes external HTTP to the VM.
768
- - **Next.js pages that call `load()` need `export const dynamic =
769
- 'force-dynamic'`** — the runtime environment doesn't exist at build time,
770
- and Next ignores runtime env for prerendered routes.
771
- - **A deployed `/rpc/<method>` returns `401` to anything but a wired peer.**
772
- Every RPC binding carries an auto-provisioned service key, so a hand-rolled
773
- `curl` is never authorized, and a provider with no wired consumers rejects
774
- everything. Not a broken deploy — reach it through a consumer, or run it
775
- locally where nothing is enforced.
776
- - **Cold starts reset service-to-service connections.** A call into a
777
- scaled-to-zero service can get `ECONNRESET`; retry it.
778
- - **Every `prisma-composer` command stops at start-up on an `effect` version
779
- conflict** (`Dependency conflict: alchemy resolves effect@...`). Another
780
- dependency floated a newer `effect` and the package manager hoisted it over
781
- Composer's pin. Do what the error says: pin the whole `effect`
782
- constellation in the app's `package.json` `overrides` (yarn: `resolutions`;
783
- pnpm: `pnpm.overrides`) — `effect` plus `@effect/sql-d1`, `@effect/sql-pg`,
784
- `@effect/vitest`, and `@effect/platform-bun`/`-node`/`-node-shared`, all at
785
- Composer's exact pin — and reinstall. (A workaround for an upstream alchemy
786
- bug: its own effect-family ranges float past what its code supports. The
787
- repo's examples carry the block.)
788
- - **The ingress buffers streaming responses.** An open SSE tail delivers
789
- nothing and times out at 60s — don't build on streamed HTTP responses.
790
-
791
- ## What Composer doesn't do yet
792
-
793
- Name the gap instead of inventing an API:
794
-
795
- - **No interactive auth.** Deploys authenticate only via a static
796
- `PRISMA_SERVICE_TOKEN`; there is no `login` flow.
797
- - **No in-memory contract bindings.** A dependency can't yet be wired to a
798
- co-located handler without HTTP; use `bootstrapService` with a loopback
799
- fake.
800
- - **RPC over HTTP is the only contract kind.** No gRPC, WebSocket, or
801
- streaming contracts.
802
-
803
- For anything else missing, check the examples and design docs in the
804
- prisma/composer repo (`examples/`, `docs/design/10-domains/`,
805
- `docs/design/90-decisions/`), then file an issue there rather than guessing.