From 17bb6e1086e81a142f06504cd8baf09693077b47 Mon Sep 17 00:00:00 2001 From: Tyler Hogarth <5163494+tylerhogarth@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:56:51 +0200 Subject: [PATCH 1/6] docs(skills): rewrite the composer skill around core concepts Rename the skill to prisma-composer-core-concepts and rebuild the body around the concepts an agent cannot observe from the code or CLI help: the declaration model, the two channels, contracts, the converge deploy model, and the failure modes. CLI reference tables, the tsconfig block, and the long worked example are dropped; the frontmatter description is now trigger-only, scoped to deploying or managing an app with Composer. Two additions: an alchemy section covering the generated stack file, failure bisection, and the effect pin, and an explicit layering statement that @prisma/composer is target-agnostic with @prisma/composer-prisma-cloud as the Prisma Cloud target. The Compute product is no longer named; "Prisma App" stays, per ADR-0026. The release scripts discover skills by frontmatter, so the rename only touches the hardcoded test path and prose references. Co-Authored-By: Claude Fable 5 Signed-off-by: Tyler Hogarth <5163494+tylerhogarth@users.noreply.github.com> --- scripts/check-skill-packaging.mjs | 2 +- scripts/set-version.ts | 2 +- scripts/skill-frontmatter.test.ts | 2 +- skills/README.md | 5 +- skills/prisma-composer-core-concepts/SKILL.md | 442 ++++++++++ skills/prisma-composer/SKILL.md | 805 ------------------ 6 files changed, 448 insertions(+), 810 deletions(-) create mode 100644 skills/prisma-composer-core-concepts/SKILL.md delete mode 100644 skills/prisma-composer/SKILL.md diff --git a/scripts/check-skill-packaging.mjs b/scripts/check-skill-packaging.mjs index b787049a..f7dd4bd3 100644 --- a/scripts/check-skill-packaging.mjs +++ b/scripts/check-skill-packaging.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// The `prisma-composer` skill must arrive in the `@prisma/composer` tarball, +// The `prisma-composer-core-concepts` skill must arrive in the `@prisma/composer` tarball, // carrying the version of the tarball it arrived in. // // That claim is only worth as much as the artifact that proves it. The diff --git a/scripts/set-version.ts b/scripts/set-version.ts index 32de23d5..de9b85d2 100644 --- a/scripts/set-version.ts +++ b/scripts/set-version.ts @@ -60,7 +60,7 @@ for (const pkg of workspacePackages) { updatedCount++; } -// The `prisma-composer` skill ships inside the `@prisma/composer` tarball, so +// The `prisma-composer-core-concepts` skill ships inside the `@prisma/composer` tarball, so // its `library_version` is part of the same lockstep stamp as the manifests: // the version a reader of the skill sees is the version they installed. const skillsDir = path.join(rootDir, 'skills'); diff --git a/scripts/skill-frontmatter.test.ts b/scripts/skill-frontmatter.test.ts index 69c2aedb..de510be5 100644 --- a/scripts/skill-frontmatter.test.ts +++ b/scripts/skill-frontmatter.test.ts @@ -91,7 +91,7 @@ describe('the shipped skill', () => { const rootVersion = JSON.parse( await readFile(path.join(repoRoot, 'package.json'), 'utf-8'), ).version; - const source = await readFile(path.join(repoRoot, 'skills/prisma-composer/SKILL.md'), 'utf-8'); + const source = await readFile(path.join(repoRoot, 'skills/prisma-composer-core-concepts/SKILL.md'), 'utf-8'); assert.deepEqual(readSkillFrontmatter(source), { library: '@prisma/composer', diff --git a/skills/README.md b/skills/README.md index fcc24569..2634c957 100644 --- a/skills/README.md +++ b/skills/README.md @@ -6,7 +6,7 @@ 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 +One skill, `prisma-composer-core-concepts`, 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`, @@ -71,7 +71,8 @@ surface change lands in both. For anyone editing the skill: 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 + no link may resolve outside `skills/prisma-composer-core-concepts/`. 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 diff --git a/skills/prisma-composer-core-concepts/SKILL.md b/skills/prisma-composer-core-concepts/SKILL.md new file mode 100644 index 00000000..7e287fe2 --- /dev/null +++ b/skills/prisma-composer-core-concepts/SKILL.md @@ -0,0 +1,442 @@ +--- +name: prisma-composer-core-concepts +metadata: + library: "@prisma/composer" + library_version: "0.15.0" + version: 2026.8.26 +description: >- + Use when deploying or managing an app that uses Prisma Composer + (`@prisma/composer`): wiring its services and Modules, running it locally, + testing composed services, or standing up / tearing down an environment. + Triggers on "prisma composer", "@prisma/composer", "prisma app", the + `prisma-composer` CLI, `compute()`, `module()`, `contract()`, + `service.load()`, `mockService`, `bootstrapService`. +--- + +# Prisma Composer core concepts + +A **Prisma App** is a tree of typed declarations composed in TypeScript and +handed to the `prisma-composer` CLI. This file covers the concepts you cannot +observe from the code or the CLI's help output: the model, the invariants, and +the failure modes. For command flags, run the command with `--help`; for +working code, read `examples/` in the prisma/composer repo. + +Two principles govern everything and are binding +(`docs/design/01-principles/`): + +1. **Your code never reads its environment.** Dependencies, configuration, + credentials, and the port all arrive through the service node, typed. + `process.env` is never the answer. +2. **Composer never bundles or transforms your code.** You build with your own + bundler; the framework assembles the built output by deterministic steps + and hands it to the configured deploy target. + +## Declarations are data + +Everything you author is a declaration: plain data describing a piece of the +app, executing nothing when imported. Three node kinds exist: + +| Kind | Declared with | Purpose | +| --- | --- | --- | +| Service | `compute()` | A running unit of your code; atomic, Composer sees only its ports | +| Resource | `postgres()`, `bucket()` | A stateful managed dependency | +| Module | `module()` | A grouping boundary; runs no code of its own, exposes typed ports | + +Nodes connect through **ports**: `deps` declares what a node requires, +`expose` declares what it offers. Wiring happens in a Module's builder via +`provision()`, and the root Module, handed to the CLI, is the App: + +```ts +// module.ts +import { module } from '@prisma/composer'; + +export default module('store', ({ provision }) => { + const catalog = provision(catalogModule); + provision(storefrontService, { deps: { catalog: catalog.rpc } }); +}); +``` + +Because ports are typed, **the compiler verifies every wire**. A dependency +wired to the wrong producer, a missing RPC handler, a config value of the +wrong shape: all of it fails `tsc`, not the deploy. Typecheck, then build, +then deploy; don't use the cloud to find out whether the app is correct. + +Composer itself is target-agnostic: `@prisma/composer` carries authoring, +testing, and the CLI, coupled to no platform. A deploy target is an extension +registered in the deploy config; `@prisma/composer-prisma-cloud` is the +Prisma Cloud target (`compute`, `postgres`, `bucket`, `envSecret`, +`envParam`, and the shared `/cron`, `/storage`, `/streams`, `/prisma-next` +modules) and the one this skill's deploy sections assume. Exactly these two +packages appear in an app's `package.json`. Compose an +existing Module before implementing a capability yourself; wiring one in is a +couple of lines. + +Within the entry graph (everything reachable from `module.ts`), relative +imports may use `./service.js` or extensionless `./service`: the CLI maps +both to the `.ts` source under Node, and Bun does this natively. + +## The service node is the only doorway + +Your runtime code receives everything from the service declaration it +imports: + +1. `service.load()`: dependencies (typed RPC clients, database bindings). +2. `service.input()`: the whole input as one schema-validated object; + credentials in it are redacting `SecretString` boxes. +3. `service.port()`: the reserved port to bind (default 3000). + +A service declaration is pure data; the server entry is what your build +produces and the platform boots: + +```ts +// service.ts +export default compute({ + name: 'auth', + deps: { db: postgres() }, + build: node({ module: import.meta.url, entry: '../dist/server.mjs' }), + expose: { rpc: authContract }, +}); + +// server.ts +const { db } = service.load(); // { url }: you construct your own client +const handler = serve(service, { + rpc: { verify: async ({ token }) => ({ ok: token.length > 0 }) }, +}); +Bun.serve({ port: service.port(), hostname: '0.0.0.0', fetch: handler }); +``` + +The consumer declares `deps: { auth: rpc(authContract) }` and gets a typed +client back from `load()`. + +## Two channels: dependencies and input + +| The value is… | Declare | Provide | Read | +| --- | --- | --- | --- | +| produced by another node | `deps: { db: postgres() }` | wire at `provision()` | `load()` | +| anything else (config or credential) | one field of the `input` schema | bind at `provision()`: literal, `envParam()`, or `envSecret()` | `input()` | + +The service declares its whole incoming configuration, plain values and +credentials together, as **one [Standard Schema](https://standardschema.dev)** +(arktype is the house choice). A credential is a field typed as +`secretString()` from `@prisma/composer/arktype`; conditional legality ("no +stripe key unless billing is on") is an ordinary schema union. The binding at +`provision()` mirrors the schema's shape; `envSecret('NAME')` names the +platform variable and never carries the value. + +Rules that bite: + +1. **Secretness is enforced by validation.** A literal bound where the schema + expects `SecretString` fails the deploy; `envSecret` bound to a plain + string field fails the same way. +2. **`envParam` values arrive as raw strings**; bind them to string fields. + The stage's platform variable is the store; the deploying shell only seeds + a missing name (and the deploy fails early, naming the variable, when both + lack it). Changing the platform value needs a redeploy. +3. **Absence is the schema's call.** An env-bound field whose variable is + unset or empty resolves to *key omitted*, which is legal only if the + schema allows it (optional field, union arm). The deploy report prints the + serialized input document (secrets ride as `{"$secret":"VAR"}` pointers) + and every key that resolved absent. +4. **The reserved `port` is outside the schema.** Read it through + `service.port()`, never `process.env`. The framework also exports `PORT` + for Next.js standalone, which binds it itself. +5. **A Module forwards a secret need without learning the platform name.** + Declare `secrets: { signingKey: secret() }` on the Module boundary and + pass the forwarded ref as a binding leaf; the parent binds the real + source. +6. `input.apiKey.expose()` is the only way to a secret's value; the box + redacts everywhere else (logs, JSON, errors). + +## Contracts and RPC + +A contract is the typed interface through which services communicate. It +lives with the service that owns it, typed by any Standard Schema validator, +and both provider (`serve()`, exhaustive over the contract's methods at +compile time) and consumer (`rpc(contract)`) reference the same value. Calls +travel as RPC over HTTP. Two behaviours are provisioned for you and must not +be reimplemented: + +1. **Service keys.** At deploy, Composer mints a distinct unguessable key per + consumer→provider binding; `serve()` returns `401` to anything else before + the handler runs. Nothing in your code declares it. Consequences: don't + build your own service-to-service auth, and don't `curl` a deployed + `/rpc/` to check it works. An unwired caller always gets `401`, + which looks like a broken deploy and isn't. Debug through a consumer, or + locally, where nothing is enforced. Keys are per binding (one leaking + can't impersonate another consumer), service-scoped (any valid key + reaches every method; split services to gate separately), rotated only by + removing the binding or destroying the stack and redeploying, and stored + in deploy-owned `COMPOSER_*` variables you never hand-edit. +2. **Idempotency and retries.** Every generated-client call carries an + `Idempotency-Key`; dropped calls retry with backoff, and `serve()` runs + one call per key, replaying the completed answer to late retries. Every + method is therefore safely retryable and no contract declares anything + about it (there is no "is this idempotent" flag; don't invent one). A + handler may take an optional third argument `(input, deps, ctx)` and read + `ctx.idempotencyKey` (`string | undefined`) if it needs exactly-once + beyond one instance's memory; most don't. Locally and in tests nothing is + provisioned, so `serve()` passes every call through: never supply a key + in test inputs. + +## Builds are yours + +You build, the framework assembles. For a plain server process, `entry` must +point at a single self-contained ESM file: everything inlined except runtime +built-ins (`bun`, `bun:*`, `node:*`). Deploy copies that one file and never +ships `node_modules`, so anything left un-inlined fails at boot, not at +deploy. Rules that bite: + +1. **Two services in one package means two separate builds**, one per entry. + A single multi-entry build splits shared code into a chunk neither output + contains. +2. **A directory build uses `dir` + `entry`** (`dir` relative to the service + module, `entry` a file inside `dir`; `../` is an error). The tree is + copied verbatim, so the server must resolve siblings against + `import.meta.url`, not the working directory. The tree must contain no + symlinks: the packager rejects them, names the link, and assembly fails. +3. **Next.js**: `next build` with `output: 'standalone'` is the whole build; + `nextjs({ module, appDir })` names the app root. Any page or action that + calls `load()` needs `export const dynamic = 'force-dynamic'`, because + the runtime environment doesn't exist at build time and Next ignores + runtime env for prerendered routes. +4. **Always build before `deploy` or `dev`.** Neither builds for you. + +Deploy configuration lives in `prisma-composer.config.ts` (or `.mts`, `.mjs`, +`.js`; nearest ancestor of the entry wins, `.ts` first within a directory). +It registers extensions (`prismaCloud()`, `nodeBuild()`, `nextjsBuild()` when +the app has a Next.js service) and the deploy-state backend +(`prismaState()`). It is read only by deploy and destroy; app code never +imports it. + +## Databases and migrations + +Two kinds of Postgres dependency: + +1. **`postgres()`**: the binding is `{ url }` and the app owns its client. +2. **`pnPostgres(...)`**: a Prisma Next-typed database. `load()` returns the + typed client Composer constructs from your data contract, so queries are + compile-time checked. One `pnContract`-wrapped value (emitted from + `contract.prisma` by `prisma contract emit`) is referenced by both the + dependency end (`deps: { db: pnPostgres(catalogData) }`) and the resource + end, which also names the `prisma.config.ts` path so the deploy's + migration step can find `migrations/`. + +**Deploys are replay-only**: they apply the migrations committed under +`migrations/` and never create schema themselves. Every schema change, +including the first schema of a new database, follows one loop: + +1. Edit `contract.prisma`. +2. `prisma contract emit` regenerates `contract.json` + `contract.d.ts`. +3. `prisma migration plan --name ` authors the migration (on an empty + graph this authors the baseline). +4. Commit `migrations/` with the change, then deploy. A fresh database + replays the whole path from empty. + +If no authored path reaches the target contract, deploy (and `dev` against a +stale local database) refuses with `MIGRATION_PATH_NOT_FOUND` and names the +exits: author the missing migration, or, when iterating against a local +database only, `prisma db update`. Never skip step 3 before a deploy. See +`examples/store/modules/catalog` for the complete pattern. + +## Deploy model: converge, don't script + +Deploy compares the declared topology against recorded deploy state and +applies only the difference. Re-deploying with nothing changed is a no-op; +removing a node removes its deployed resource. The Prisma Cloud target +requires exactly two environment variables: `PRISMA_SERVICE_TOKEN` and +`PRISMA_WORKSPACE_ID`. There is no interactive login. + +**Stages.** A stage is an environment name chosen on the command line +(`--stage pr-42`), never written in the topology. The identical graph +deploys everywhere. On the Prisma Cloud target, a Prisma App is one Project +and a stage is a Branch of it, with its own running services, its own empty +database, its own configuration. 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 destroy is an error, +and `--stage` with `--production` is too. Destroying a stage deletes its +Branch after removing its resources. The production Branch is never deleted, +only the resources inside it; destroying production also deletes the Project +once it's empty, but a Project still holding another stage's resources is +kept. Destroy never creates anything: destroying a never-deployed stage +fails rather than standing one up. + +**The engine underneath is alchemy.** Convergence is executed by +[alchemy](https://alchemy.run), a third-party infrastructure-as-code engine +that arrives as an ordinary, exactly-pinned npm dependency of +`@prisma/composer` (2.0.0-beta.74 at this library version). Your code never +imports or configures it; consult alchemy's own docs for the engine itself. +What matters operationally: + +1. Deploy and destroy write the pipeline's results to a generated, gitignored + stack file at `.prisma-composer/alchemy.run.ts`, then run the alchemy CLI + against it as a child process; `dev` does the same at + `.prisma-composer/dev/alchemy.run.ts` with local providers. The file + carries the computed values as literals but reads credentials via + `fromEnv()`, so nothing sensitive lands on disk, and it is regenerated + every run: output, not configuration, never edited. +2. Failures are bisectable through that file. A failing deploy names its + path; running `alchemy deploy .prisma-composer/alchemy.run.ts` directly + separates "the framework computed the wrong thing" from "the engine or + platform rejected the right thing". An engine failure surfaces as + `DEPLOY.ENGINE_FAILED` carrying the exit code and that reproduce command; + the child's live output streams to the terminal either way. +3. Destroy evaluates the same stack program as deploy, and evaluating it + packages the assembled bundles, so **an app must be built before it can + be torn down**. +4. alchemy is why the `effect` pin exists: it resolves the `effect` + constellation, and a hoisted newer `effect` halts every command (failure + mode 1 below). + +**The deploy report** ends with the app's own topology: authored names, the +platform resource each became, and public URLs. Read ids out of it rather +than hunting in the Console. A URL appears only where the address is +genuinely public: a service prints one, a database never does, and a +node whose product is secret material reports no resource line at all. + +**Connection contract refusals.** A connection declares the values it needs +by name; a producer that omits one fails the deploy, naming the edge, the +param, and what the producer did supply: + +``` +Connection input "auth.db" declares param "url", but its producer "db" did not +supply it — the producer's outputs carry [host]. +``` + +This is a deploy-time refusal, not a broken deploy, and it can appear on an +app whose code didn't change (the gap used to pass silently as `undefined` +and crash the consumer at boot). Fix whichever end is wrong; don't mark the +param `optional` unless absent really is legal. Only reachable if you +authored the connection or an extension on one side. + +**Driving deploys from code.** `@prisma/composer/control` exposes typed +`deploy`, `destroy`, `dev`, and `log` returning structured results. Failures +come back as `{ ok: false, failure }` with a dotted `failure.code` from a +closed registry (e.g. `ASSEMBLE.BUILD_FAILED`, `DEPLOY.ENGINE_FAILED`, +`DEPS.EFFECT_VERSION_CONFLICT`); branch on the code, not the message. A +non-structured rejection out of an operation is a bug in composer, not an +expected failure. + +## Local development + +`prisma-composer dev module.ts` runs the whole app on this machine, wired as +it deploys, against local emulators. No cloud credentials are needed or +read. Concepts that surprise: + +1. It runs the same pipeline as deploy, so **build first**, exactly like + deploy. It watches built output and restarts a service when its build + changes. +2. Ctrl-C stops the app's processes but leaves local databases, buckets, and + their data up: the next `dev` is a warm start. `--fresh` wipes this app's + local instances and data first. +3. `dev` does not print service logs; `prisma-composer log` is a separate, + read-only command that follows the already-running app's merged logs. It + never builds, provisions, starts, or stops anything. +4. An unset secret doesn't block a local run: it becomes a placeholder plus a + warning, and only the code path that spends it fails, at the external + service it calls. +5. Windows isn't supported yet. + +## Testing is an environment seam + +A test is just another environment: one where you decide what `load()` and +`input()` return, 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` | + +`mockService` returns a copy of the service whose `load()` yields your +doubles (type-checked against the declared deps) and whose `input()` yields +the object passed under the reserved `input` key (required exactly when the +service declares an input schema; handed over as-is, not validated). Wiring +the module substitution is your runner's job (`vi.mock` in Vitest, +`mock.module` in bun test). + +`bootstrapService` boots the service's real built entry in-process against a +config you choose; drive it over real HTTP. Gotchas: + +1. `service.port` must be concrete: the entry self-listens, and no + OS-assigned port is reported back. +2. There is no `close()`; run each integration-test file in its own process + (bun test does). +3. Next.js services take a third argument, a boot thunk, resolved with + `standaloneServerPath` from `@prisma/composer/nextjs/control`. +4. A service with an input schema takes `input` in the config, a binding + exactly like `provision()`'s, run through the real serialize/read path. + +A dependency's type is its contract, so any value of that shape is a valid +double: a bare object, the real client over an in-memory handler, or a real +local server. Ship a dependency's fake from its own package as a `/fake` +entry point, outside `src/`, so the fake and the real service share one +contract. + +## Building blocks and extensions + +First-party Modules ship inside `@prisma/composer-prisma-cloud` and +provision exactly like your own: + +| 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` | + +`bucket()` (imported alongside `postgres`) is a raw S3-compatible bucket: +the dependency end receives `{ url, bucket, accessKeyId, secretAccessKey }`, +shape-compatible with `/storage`'s `s3()` dependency, so a service wired to +`s3()` can be rewired to a `bucket` resource unchanged. + +An extension (a package bringing its own Modules, resources, or deploy +target) is published on npm as `prisma-composer-*`. The ecosystem is new: +today the blocks above plus your own Modules are the whole set, so verify a +`prisma-composer-*` package exists on npm before reaching for it. + +## Failure modes quick reference + +1. **Every `prisma-composer` command halts at start-up on an `effect` + version conflict** (`Dependency conflict: alchemy resolves effect@...`). + Another dependency floated a newer `effect` and the package manager + hoisted it over Composer's pin. Pin the whole `effect` constellation in + the app's `package.json` `overrides` (yarn: `resolutions`; pnpm: + `pnpm.overrides`): `effect` plus `@effect/sql-d1`, `@effect/sql-pg`, + `@effect/vitest`, and `@effect/platform-bun`/`-node`/`-node-shared`, all + at Composer's exact pin, then reinstall. The repo's examples carry the + block. +2. **A deployed `/rpc/` returns `401` to anything but a wired + peer.** Not a broken deploy; see Contracts above. +3. **Scale-to-zero closes idle database connections.** A persistent client + crashes into a 502 restart loop unless the pool is small and + reconnect-friendly (`new SQL({ url, max: 1, idleTimeout: 10 })` for Bun) + and the process logs `uncaughtException`/`unhandledRejection` instead of + dying. +4. **Cold starts reset service-to-service connections.** A call into a + scaled-to-zero service can get `ECONNRESET`; retry it. +5. **Bind `0.0.0.0`, not loopback.** The platform routes external HTTP to + the VM; + a loopback-only listener is unreachable. +6. **The ingress buffers streaming responses.** An open SSE tail delivers + nothing and times out at 60s; don't build on streamed HTTP responses. +7. **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 given an + explicit `id`. +8. **`MIGRATION_PATH_NOT_FOUND`**: see Databases above; author the missing + migration, don't skip the plan step. + +## What Composer doesn't do yet + +Name the gap instead of inventing an API: + +1. **No interactive auth.** Deploys authenticate only via a static + `PRISMA_SERVICE_TOKEN`; there is no `login` flow. +2. **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. +3. **RPC over HTTP is the only contract kind.** No gRPC, WebSocket, or + streaming contracts. + +For anything else missing, check `examples/`, `docs/design/10-domains/`, and +`docs/design/90-decisions/` in the prisma/composer repo, then file an issue +there rather than guessing. diff --git a/skills/prisma-composer/SKILL.md b/skills/prisma-composer/SKILL.md deleted file mode 100644 index 1d1d019d..00000000 --- a/skills/prisma-composer/SKILL.md +++ /dev/null @@ -1,805 +0,0 @@ ---- -name: prisma-composer -metadata: - library: "@prisma/composer" - library_version: "0.15.0" -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, declare the service - input (config and secrets as one schema, read back with `input()`), - compose the ready-made cron/storage/streams Modules, provision a - raw S3-compatible object-store bucket with `bucket()`, find extensions (npm - packages named `prisma-composer-*`), test with `mockService`/`bootstrapService`, - run the whole app locally with `prisma-composer dev` and tail its logs with - `prisma-composer log`, and deploy with `prisma-composer deploy` (stages, - destroy). Use when building a Prisma App, wiring a service dependency, adding - a Postgres database, adding scheduled jobs / blob storage / event streams / a - raw bucket, writing tests for composed services, running an app locally, - reading its logs, or deploying/tearing down an environment. Triggers on - "prisma composer", "@prisma/composer", "prisma app", "compute()", - "service.load()", "module()", "contract()", "mockService", - "bootstrapService", "prisma-composer dev", "prisma-composer log", - "prisma-composer deploy", "--stage", "--fresh", "--tail", - "prisma-composer destroy", "prisma-composer-", "bucket()". ---- - -# 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.input()` — the service's whole input, one schema-validated typed - object; credentials in it are redacting `SecretString` boxes -- `service.port()` — the reserved port to bind (default 3000), typed; never - `process.env` - -The framework never bundles or transforms your code. You build your app with -whatever bundler you like (`bun build`, `next build`); `prisma-composer deploy` -assembles the built output and provisions it on Prisma Cloud (Compute + Prisma -Postgres). - -Two things make building here fast and hard to get wrong — lean on both: - -- **Compose before you write.** Reach for an existing Module (below) before - implementing a capability yourself; wiring one in is a couple of lines. -- **The compiler checks the wiring.** A dependency wired to the wrong - producer, a missing RPC handler, a config value of the wrong shape — all of - it fails `tsc`, not the deploy. Typecheck, then build, then deploy; don't - reach for the cloud to find out whether the app is correct. - -Two packages, and only two, appear in your `package.json`: - -| Package | Provides | -| --- | --- | -| `@prisma/composer` | Core authoring: `module`, `secret`, `isSecretString`, `/arktype` (the `secretString()` schema leaf), `/rpc`, `/node`, `/nextjs`, `/config`, `/testing`, the `prisma-composer` CLI | -| `@prisma/composer-prisma-cloud` | The Prisma Cloud target: `compute`, `postgres`, `envSecret`, `envParam`, `/control`, `/testing`, and the shared `/cron`, `/storage`, `/streams`, `/prisma-next` modules | - -## tsconfig and import specifiers - -Within the entry graph (everything reachable from `module.ts`) relative -imports may use `./service.js` or extensionless `./service`. The CLI maps -`.js` and extensionless specifiers to the matching `.ts` source under Node; -Bun does this natively. - -A minimal tsconfig: - -```jsonc -{ - "compilerOptions": { - "target": "ES2022", - "module": "Preserve", - "moduleResolution": "bundler", - "noEmit": true, - "strict": true, - "skipLibCheck": true, - "types": ["bun"] - }, - "include": ["module.ts", "src"] -} -``` - -## 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/service-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/service-rpc'; -import { SQL } from 'bun'; -import service from './service.ts'; - -const { db } = service.load(); // { url } — you build your own client -const port = service.port(); // the reserved port, resolved (default 3000) - -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/service-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)}

; -} -``` - -**Service-to-service calls are authenticated for you.** At deploy the -framework mints a distinct, unguessable **service key** per consumer→provider -binding: the consumer's client sends it on every call, and `serve()` returns -`401` to anything else *before* the handler runs. Nothing declares it — no key -in the contract, the service, the module, or the app's code. - -Two rules follow for you specifically: **don't build your own -service-to-service auth** on top of this, and **don't tell a user to `curl` a -deployed `/rpc/` to check it works** — an unwired caller always gets -`401`, which looks like a broken deploy and isn't. Debug through a consumer, -or locally. - -**Calls carry an idempotency key and retry safely for you.** Every call the -generated client makes carries an `Idempotency-Key`; a call dropped while the -target cold-starts is retried with a backoff, and `serve()` runs one call per -key — a retry that arrives after the first completed replays that answer -instead of re-running the handler. So every method is safely retryable and no -contract declares anything about it (do not add an "is this idempotent" flag — -the framework does not have one). Two consequences for you: a handler may take -an **optional third argument** `(input, deps, ctx)` and read `ctx.idempotencyKey` -(`string | undefined` — it's absent for a keyless caller) if it needs exactly-once -beyond one instance's memory (most don't); and a request without the header is -served once without deduplication rather than rejected, so a hand-rolled probe -works but gets no retry safety. - -| | | -| --- | --- | -| Locally / in tests | nothing is provisioned, so `serve()` passes every call through — never supply a key in `inputs` | -| Per binding | two consumers of one provider hold different keys, so one leaking can't impersonate the other | -| Scope | service-level — any valid key reaches every method that service exposes; split into two services to gate separately | -| Rotation | remove the binding (or destroy the stack) and redeploy — a plain redeploy is a no-op, not a rotation | -| Storage | `COMPOSER_*` variables the deploy owns and rewrites; never hand-edit one | - -It's a capability token ("I'm a service this app wired to you"), not a secret, -and its value lives in deploy state — deliberately unlike `secret()`, whose -value the framework never holds. `docs/design/90-decisions/ADR-0030…` in the -prisma/composer repo carries the reasoning. - -## 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), -`input` (the service's input binding — required exactly when it declares an -input schema, see § Service input), and `secrets` (bind a module boundary's -forwarded secret needs). - -## Builds are yours - -The framework assembles only what you built — users build, the framework -assembles. For a plain server process, `entry` must point at a single -self-contained ESM file: everything inlined except runtime built-ins (`bun`, -`bun:*`, `node:*`), which the deploy VM provides. Deploy copies that one file -and never ships `node_modules`, so anything left un-inlined fails at boot. Any -bundler that produces such a file works. With bun: - -```sh -bun build src/server.ts --target=bun --outfile dist/server.mjs -``` - -Two services in one package means two separate builds, one per entry — not one -multi-entry build, which would split shared code into a chunk neither output -contains. - -If the build emits a directory rather than one file — a server plus the client -bundle, CSS and images it serves, as Bun's HTML import produces — name the -directory with `dir` and the booting file inside it with `entry`: - -```ts -build: node({ module: import.meta.url, dir: '../dist/server', entry: 'server.js' }) -``` - -`dir` resolves relative to the service module; `entry` resolves inside `dir` -and may be nested. Deploy copies the tree verbatim and boots the named file, -so the server must resolve its siblings against `import.meta.url`, not the -working directory. Nothing is inferred, and two rules bite: the tree must -contain no symlinks (the packager rejects them — assembly fails and names the -link), and `entry` must be a file inside `dir` (`../` is an error, not an -escape). Omit `dir` for the single-file form. - -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` usually sits next to `module.ts`, but it may live in -any ancestor directory: the CLI searches the entry's directory first, then each -parent, and uses the nearest one. It is read only by `prisma-composer -deploy`/`destroy`, never imported by app code. A plain-JavaScript project can -name it `prisma-composer.config.mjs` or `.js` to keep it out of its TypeScript -build (a build with `allowJs` still needs an explicit `exclude`); `.mts` is the -TypeScript ES-module spelling. Within one directory `.ts` wins, then `.mts`, -`.mjs`, `.js`: - -```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(), // deploy state, in its own database on the stage's branch -}); -``` - -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. -Construct it in your server entry, as in the auth example above. - -**`pnPostgres(...)`** — a Prisma Next-typed database: `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 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.config.ts` path, which the deploy's migration step loads to find -`migrations/` — committed migrations are replayed at deploy, before the -service starts: - -```ts -const db = provision( - pnPostgres({ name: 'database', contract: catalogData, config: './prisma.config.ts' }), -); -``` - -(`pnPostgres` is both ends: the contract alone is the dependency end; the -options object is the resource end.) - -The deploy is replay-only: it applies the migrations committed under -`migrations/` and never creates schema itself. Every schema change (including -the very first schema of a new database) follows the same loop: - -1. Edit `contract.prisma`. -2. `prisma contract emit` — regenerates `contract.json` + `contract.d.ts`. -3. `prisma migration plan --name ` — authors the migration into - `migrations/` (on an empty graph this authors the baseline, - empty → your schema). -4. Commit `migrations/` with the change, then deploy. A fresh database - replays the whole path from empty. - -If no authored path reaches the target contract, the deploy (and -`prisma-composer dev` against a stale local database) refuses with -`MIGRATION_PATH_NOT_FOUND` and names the exits: author the missing migration -as above, or — when iterating against a local database only — bring it along -directly with `prisma db update`. Never skip step 3 before a deploy. - -See `examples/store/modules/catalog` in the prisma/composer repo for the -complete pattern. - -## Object Storage - -`bucket` is a raw S3-compatible object-store bucket, imported alongside `postgres`: - -```ts -import { bucket, compute } from '@prisma/composer-prisma-cloud'; - -// service.ts — dependency end: receives { url, bucket, accessKeyId, secretAccessKey } -export default compute({ name: 'uploads', deps: { store: bucket() } }); - -// module.ts — resource end: provisions the bucket and mints a keypair -const store = provision(bucket({ name: 'uploads' })); -provision(uploadsService, { deps: { store } }); -``` - -Use any S3-compatible client with the binding: the shape matches the standard S3 -config and is also compatible with the `s3()` dependency from `/storage`, so any -service wired to `s3()` can be rewired to a `bucket` resource without changing -the service declaration. - -## 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 }, - input: { signingKey: secrets.signingKey }, // forwarded ref as a binding leaf - }); - 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. - -### The building blocks you can compose - -Modules are the building blocks: provision one, wire its exposed port, and -you're done — you never reimplement what a Module already owns. The -first-party set ships inside `@prisma/composer-prisma-cloud`. It's small, and -growing: - -| 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` | - -**Finding more.** A Composer extension — a package that brings its own -Modules, resources, or deploy target — is published on npm under the name -`prisma-composer-*`. That name is the convention, so it's how you look for -one. The ecosystem is new: today the blocks above plus the app Modules you -write are the whole set, so don't reach for a `prisma-composer-*` package -without checking that it actually exists on npm first. - -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 } }); -``` - -## Service input - -Choosing the channel is most of the decision: - -| The value is… | Declare | Provide | Read | -| --- | --- | --- | --- | -| produced by another node | `deps: { db: postgres() }` | wire at `provision()` | `load()` | -| anything else — config or credential | one field of the `input` schema | bind at `provision()`: literal, `envParam()`, or `envSecret()` | `input()` | - -The service declares its whole incoming configuration — plain values and -credentials together — as **one -[Standard Schema](https://standardschema.dev)** (arktype is the house -choice). A credential is a field typed as the redacting `SecretString` box; -conditional legality ("no stripe key unless billing is on") is an ordinary -schema union: - -```ts -// service.ts — the shapes that are legal -import { secretString } from '@prisma/composer/arktype'; -import { type } from 'arktype'; - -compute({ - name: 'scheduler', - input: type({ - jobs: type({ jobId: 'string', every: 'string' }).array(), - 'region?': 'string', - apiKey: secretString(), - }), - // ... -}); - -// module.ts — where each value comes from; the binding mirrors the schema's shape -import { envParam, envSecret } from '@prisma/composer-prisma-cloud'; -provision(scheduler, { - input: { - jobs: [{ jobId: 'tick', every: '60s' }], // a literal - region: envParam('REGION'), // a per-stage platform variable - apiKey: envSecret('SCHEDULER_API_KEY'), // a credential — name only, never the value - }, -}); - -// server.ts — one call, one validated typed object -const input = service.input(); -input.apiKey.expose(); // the only way to a secret's value; the box redacts everywhere else -``` - -Rules that bite: - -- **Secretness is enforced by validation**: a literal bound where the schema - expects `SecretString` fails the deploy, and `envSecret` bound to a plain - string field fails the same way. Don't put credentials in plain fields. -- **`envParam` values arrive as raw strings** — bind them to string fields. - The stage's platform variable is the store; the deploying shell only seeds - it (preflight copies a missing name up from the shell, and fails early, - naming the variable, when both lack it). Changing the platform value needs - a redeploy. -- **Absence is the schema's call**: an env-bound field whose variable is - unset (or empty) resolves to *key omitted* — legal only if the schema says - so (optional field, union arm). The deploy report prints the serialized - input document (secret-free: secrets ride as `{"$secret":"VAR"}` pointers) - and every key that resolved absent. -- **The reserved `port` (default 3000) is outside the schema** — read it - through `service.port()` (a sibling of `service.origin()`), never - `process.env`. The framework also exports `PORT` for Next.js standalone, - which binds it itself. -- A module forwards a secret need without learning the platform name - (the auth Module above); the forwarded ref is a binding leaf. - -`examples/env-param` and `examples/storefront-auth` in the prisma/composer -repo are the working versions. - -## 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 `input()` -yields the object you pass under the reserved `input` key, in one flat -object (required exactly when the service declares an input schema; handed -over as-is, not validated). 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`. - `bootstrapService` exports the resolved port as `process.env.PORT` before - booting, which is what Next's standalone server binds. -- **A service with an input schema takes `input`** in the config — a binding - exactly like `provision()`'s, run through the real serialize/read path, so - `input()` in the booted entry sees what a deploy would produce. - -**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. - -## Running locally - -`prisma-composer dev module.ts` runs the whole app on this machine — every -service, its Postgres and buckets, wired as they deploy — with **no cloud -credentials** (no `PRISMA_*`). It runs the same pipeline as deploy against -local emulators, so build first, exactly like deploy: - -```sh -turbo run build && prisma-composer dev module.ts -``` - -It prints each service's local URL (the "front door"), watches built output -and restarts a service when its build changes, and runs until Ctrl-C. Ctrl-C -stops the app's processes but leaves the local databases, buckets, and their -data up, so the next `dev` is a warm start; `--fresh` wipes this app's local -instances and data first. - -`dev` does **not** print service logs — that would bury the front door once -several services run. Logs are their own command: - -| You want to… | Run | -| --- | --- | -| Run the app locally | `prisma-composer dev module.ts` | -| Start clean (wipe local data) | `prisma-composer dev module.ts --fresh` | -| Tail every service's logs | `prisma-composer log module.ts` | -| Tail one service | `prisma-composer log module.ts
` | -| Show more history first | `prisma-composer log module.ts --tail ` | - -`prisma-composer log` follows the merged logs of the already-running app, each -line prefixed with its service (`[catalog.service] …`); pass a dotted address -to narrow to one. It only reads — it never builds, provisions, starts, or -stops anything. `--tail ` sets how much recent history to show before live -output (default 20; `0` for live-only). An unset secret doesn't block a local -run: it becomes a placeholder plus a warning, and only the code path that -spends it fails, at the real external service it calls. Windows isn't -supported yet. - -## 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 — 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. Destroying production also -deletes the Project itself once it's empty, so hand-run stacks don't leave -behind empty Projects — but a Project still holding another stage's resources -is kept. 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 -``` - -### What a deploy prints - -A deploy ends by printing the app's own topology — authored names, the -platform resource each became, and public URLs. The tree is the module -structure (`auth.api` is the `api` service inside the `auth` module): - -``` -storefront-auth -├─ auth -│ └─ api compute-service cps_abc123 -│ https://xyz.ewr.prisma.build -├─ db postgres-database db_def456 -└─ web compute-service cps_ghi789 - https://uvw.ewr.prisma.build -``` - -Read ids out of this rather than telling the user to go hunting in the -Console. A URL appears only where the address is genuinely public — a compute -service prints one, a database never does (it has a connection string, not a -public endpoint), and a node whose product is secret material (an -`s3-credentials` keypair) reports no resource line at all. A node that -published nothing reportable still appears, marked `(no entities reported)`. - -Older deploys ended with a raw `{ outputs: {} }` blob from the deploy engine — -always empty, never about the app. It is gone; nothing configured it and -nothing consumed it. - -### The connection contract is checked at deploy - -A connection declares the values it needs by name, and the producer on the -other end must supply them. A producer that omits one fails the deploy, naming -the edge, the param, and what the producer did supply: - -``` -Connection input "auth.db" declares param "url", but its producer "db" did not -supply it — the producer's outputs carry [host]. -``` - -Fix it at whichever end is wrong: add the name to the outputs the producer -returns from its lowering, or mark the param `optional` on the connection if absent is -genuinely legal (the consumer then reads `undefined`). - -This is a deploy-time refusal, not a broken deploy — and it can appear on an -app whose code didn't change. The gap used to pass silently: the value reached -the consumer as `undefined`, went into its environment, and crashed *that* -service at boot, blaming the reader instead of the supplier. Don't route around -it by making the param optional unless absent really is valid; that reinstates -the silent `undefined`. - -Only reachable if you authored the connection or the extension on one side — -every shipped block supplies what it declares. - -### Driving deploys from code - -`@prisma/composer/control` exposes the CLI's operations in-process: typed -`deploy`, `destroy`, `dev`, and `log` returning structured results — no argv, -no CLI rendering, no exit codes (the spawned deploy engine's own inherited -output can still reach the host terminal). The CLI itself is a renderer over -them. - -```ts -import { deploy } from '@prisma/composer/control'; -const result = await deploy({ entry: 'module.ts', stage: 'pr-42' }); -// result: { ok: true, value: { summary? } } | { ok: false, failure } -``` - -- Failures come back as `{ ok: false, failure }` where `failure` is a - structured error: branch on its dotted `failure.code` (e.g. - `ASSEMBLE.BUILD_FAILED`, `DEPLOY.ENGINE_FAILED` — ADR-0044's closed - registry), with the same fix-naming `message`/`why`/`fix` the CLI renders. - An engine failure's `meta.diagnostics` (exit code, reproduce command; read - it with the exported `executionDiagnostics(failure)`) describes the current - execution mechanism — branch on `code`/`message`/`cause` for anything - durable. The effect version conflict is `DEPS.EFFECT_VERSION_CONFLICT`, and - importing the module executes nothing until an operation runs. A - non-structured rejection out of an operation is a bug in composer, not an - expected failure. -- `destroy` takes `target: { kind: 'production' } | { kind: 'stage', stage }` - — explicit, never defaulted. -- `deploy`'s `summary` (the deployed topology) is best-effort; `undefined` on - a successful deploy is normal. -- The deploy engine's live output still streams to the host process's stdio — - the current mechanism; the operations don't capture it. -- `dev` resolves to `{ ok: true, value: session }` or a failure; the - session is `{ endpoints, stop(), closed }` with progress via `onEvent`, and - the host owns signal handling. `log` resolves to - `{ ok: true, value: { appName, services, lines } }` or a failure, where - `lines` is an `AsyncIterable` ended by a caller-owned `AbortSignal` (or by - the consumer stopping early); zero running services is a valid result, not - an error. - -## 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. -- **A deployed `/rpc/` returns `401` to anything but a wired peer.** - Every RPC binding carries an auto-provisioned service key, so a hand-rolled - `curl` is never authorized, and a provider with no wired consumers rejects - everything. Not a broken deploy — reach it through a consumer, or run it - locally where nothing is enforced. -- **Cold starts reset service-to-service connections.** A call into a - scaled-to-zero service can get `ECONNRESET`; retry it. -- **Every `prisma-composer` command stops at start-up on an `effect` version - conflict** (`Dependency conflict: alchemy resolves effect@...`). Another - dependency floated a newer `effect` and the package manager hoisted it over - Composer's pin. Do what the error says: pin the whole `effect` - constellation in the app's `package.json` `overrides` (yarn: `resolutions`; - pnpm: `pnpm.overrides`) — `effect` plus `@effect/sql-d1`, `@effect/sql-pg`, - `@effect/vitest`, and `@effect/platform-bun`/`-node`/`-node-shared`, all at - Composer's exact pin — and reinstall. (A workaround for an upstream alchemy - bug: its own effect-family ranges float past what its code supports. The - repo's examples carry the block.) -- **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 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. From 7d25f4375cc87f1aab2f5e4ebd55b6750d0d14aa Mon Sep 17 00:00:00 2001 From: Tyler Hogarth <5163494+tylerhogarth@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:06:18 +0200 Subject: [PATCH 2/6] docs(skills): address review feedback on compile vs deploy checks The compiler-check claim overreached: env-bound input values exist only at deploy, so secret-binding mismatches and missing platform variables are deploy-time refusals, not tsc failures. Say so where the claim is made. Also reword the unclear "names the exits" phrase in the migration section and label the error-example fence for markdownlint (MD040). Co-Authored-By: Claude Fable 5 Signed-off-by: Tyler Hogarth <5163494+tylerhogarth@users.noreply.github.com> --- skills/prisma-composer-core-concepts/SKILL.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/skills/prisma-composer-core-concepts/SKILL.md b/skills/prisma-composer-core-concepts/SKILL.md index 7e287fe2..bdace98b 100644 --- a/skills/prisma-composer-core-concepts/SKILL.md +++ b/skills/prisma-composer-core-concepts/SKILL.md @@ -57,9 +57,12 @@ export default module('store', ({ provision }) => { ``` Because ports are typed, **the compiler verifies every wire**. A dependency -wired to the wrong producer, a missing RPC handler, a config value of the -wrong shape: all of it fails `tsc`, not the deploy. Typecheck, then build, -then deploy; don't use the cloud to find out whether the app is correct. +wired to the wrong producer, a missing RPC handler, a literal input value of +the wrong shape: all of it fails `tsc`, not the deploy. Env-bound input is +the exception: those values exist only at deploy, so secret-binding +mismatches and missing platform variables surface as early deploy-time +refusals instead (see Two channels below). Typecheck, then build, then +deploy; don't use the cloud to find out whether the wiring is correct. Composer itself is target-agnostic: `@prisma/composer` carries authoring, testing, and the CLI, coupled to no platform. A deploy target is an extension @@ -233,8 +236,9 @@ including the first schema of a new database, follows one loop: replays the whole path from empty. If no authored path reaches the target contract, deploy (and `dev` against a -stale local database) refuses with `MIGRATION_PATH_NOT_FOUND` and names the -exits: author the missing migration, or, when iterating against a local +stale local database) refuses with `MIGRATION_PATH_NOT_FOUND`; its message +lists the two ways out: author the missing migration, or, when iterating +against a local database only, `prisma db update`. Never skip step 3 before a deploy. See `examples/store/modules/catalog` for the complete pattern. @@ -298,7 +302,7 @@ node whose product is secret material reports no resource line at all. by name; a producer that omits one fails the deploy, naming the edge, the param, and what the producer did supply: -``` +```text Connection input "auth.db" declares param "url", but its producer "db" did not supply it — the producer's outputs carry [host]. ``` From 24eb03d76621b9e8a3f778eca8e9b3d121503f89 Mon Sep 17 00:00:00 2001 From: Tyler Hogarth <5163494+tylerhogarth@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:12:30 +0200 Subject: [PATCH 3/6] docs(skills): point at prisma.io/docs and drop CLI-flag references The intro now states the skill's scope directly: structures, hierarchies, relationships, and workflows, not a CLI reference. Because the platform moves fast, it names https://www.prisma.io/docs as the current documentation and directs agents to `--help` for individual commands. Flag references outside workflows are reworded conceptually: stage selection, destroy targeting, and the dev fresh-start behaviour. The migration loop, the standalone Next build, and the alchemy bisection keep their commands; they describe workflows. Co-Authored-By: Claude Fable 5 Signed-off-by: Tyler Hogarth <5163494+tylerhogarth@users.noreply.github.com> --- skills/prisma-composer-core-concepts/SKILL.md | 51 ++++++++++--------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/skills/prisma-composer-core-concepts/SKILL.md b/skills/prisma-composer-core-concepts/SKILL.md index bdace98b..0d10081b 100644 --- a/skills/prisma-composer-core-concepts/SKILL.md +++ b/skills/prisma-composer-core-concepts/SKILL.md @@ -16,10 +16,13 @@ description: >- # Prisma Composer core concepts A **Prisma App** is a tree of typed declarations composed in TypeScript and -handed to the `prisma-composer` CLI. This file covers the concepts you cannot -observe from the code or the CLI's help output: the model, the invariants, and -the failure modes. For command flags, run the command with `--help`; for -working code, read `examples/` in the prisma/composer repo. +handed to the `prisma-composer` CLI. This file covers structures, +hierarchies, relationships, and workflows: the concepts you cannot observe +from the code or the CLI's help output. It is not a CLI reference; discover +any individual command and its flags with `--help`. The Prisma platform +moves fast, so treat this file as the stable conceptual core and find +current, fuller documentation at . For working +code, read `examples/` in the prisma/composer repo. Two principles govern everything and are binding (`docs/design/01-principles/`): @@ -250,20 +253,20 @@ removing a node removes its deployed resource. The Prisma Cloud target requires exactly two environment variables: `PRISMA_SERVICE_TOKEN` and `PRISMA_WORKSPACE_ID`. There is no interactive login. -**Stages.** A stage is an environment name chosen on the command line -(`--stage pr-42`), never written in the topology. The identical graph -deploys everywhere. On the Prisma Cloud target, a Prisma App is one Project -and a stage is a Branch of it, with its own running services, its own empty -database, its own configuration. A stage -name must be a valid git ref name; an invalid name is a hard error. +**Stages.** A stage is an environment name chosen on the command line at +deploy time, never written in the topology. The identical graph deploys +everywhere. On the Prisma Cloud target, a Prisma App is one Project and a +stage is a Branch of it, with its own running services, its own empty +database, its own configuration. 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 destroy is an error, -and `--stage` with `--production` is too. Destroying a stage deletes its -Branch after removing its resources. The production Branch is never deleted, -only the resources inside it; destroying production also deletes the Project -once it's empty, but a Project still holding another stage's resources is -kept. Destroy never creates anything: destroying a never-deployed stage -fails rather than standing one up. +and naming a stage and production together is too. Destroying a stage +deletes its Branch after removing its resources. The production Branch is +never deleted, only the resources inside it; destroying production also +deletes the Project once it's empty, but a Project still holding another +stage's resources is kept. Destroy never creates anything: destroying a +never-deployed stage fails rather than standing one up. **The engine underneath is alchemy.** Convergence is executed by [alchemy](https://alchemy.run), a third-party infrastructure-as-code engine @@ -323,19 +326,19 @@ expected failure. ## Local development -`prisma-composer dev module.ts` runs the whole app on this machine, wired as -it deploys, against local emulators. No cloud credentials are needed or -read. Concepts that surprise: +The `dev` command runs the whole app on this machine, wired as it deploys, +against local emulators. No cloud credentials are needed or read. Concepts +that surprise: 1. It runs the same pipeline as deploy, so **build first**, exactly like deploy. It watches built output and restarts a service when its build changes. 2. Ctrl-C stops the app's processes but leaves local databases, buckets, and - their data up: the next `dev` is a warm start. `--fresh` wipes this app's - local instances and data first. -3. `dev` does not print service logs; `prisma-composer log` is a separate, - read-only command that follows the already-running app's merged logs. It - never builds, provisions, starts, or stops anything. + their data up: the next `dev` is a warm start. Starting clean, wiping + this app's local instances and data first, is an explicit opt-in flag. +3. `dev` does not print service logs; `log` is a separate, read-only command + that follows the already-running app's merged logs. It never builds, + provisions, starts, or stops anything. 4. An unset secret doesn't block a local run: it becomes a placeholder plus a warning, and only the code path that spends it fails, at the external service it calls. From 78dda3e2268d0b608b2117a3239283ca0d584f15 Mon Sep 17 00:00:00 2001 From: Tyler Hogarth <5163494+tylerhogarth@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:43:23 +0200 Subject: [PATCH 4/6] docs(skills): production Branch is removed via Project deletion The destroy paragraph said the production Branch is never deleted while also saying the emptied Project is; Project deletion removes every Branch in it, production included. Say the Branch is never deleted directly, and that the Project deletion takes it along. Co-Authored-By: Claude Fable 5 Signed-off-by: Tyler Hogarth <5163494+tylerhogarth@users.noreply.github.com> --- skills/prisma-composer-core-concepts/SKILL.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/skills/prisma-composer-core-concepts/SKILL.md b/skills/prisma-composer-core-concepts/SKILL.md index 0d10081b..1a8dabe3 100644 --- a/skills/prisma-composer-core-concepts/SKILL.md +++ b/skills/prisma-composer-core-concepts/SKILL.md @@ -262,10 +262,12 @@ an invalid name is a hard error. **Destroy** always requires an explicit target: a bare destroy is an error, and naming a stage and production together is too. Destroying a stage -deletes its Branch after removing its resources. The production Branch is -never deleted, only the resources inside it; destroying production also -deletes the Project once it's empty, but a Project still holding another -stage's resources is kept. Destroy never creates anything: destroying a +deletes its Branch after removing its resources. Destroying production +removes only the resources inside the production Branch, never the Branch +itself directly; once the Project is empty it is deleted too, and that +deletion takes the production Branch with it. A Project still holding +another stage's resources is kept. Destroy never creates anything: +destroying a never-deployed stage fails rather than standing one up. **The engine underneath is alchemy.** Convergence is executed by From 3b889d61ddb54b53cdc7151b0d56f283d056e29c Mon Sep 17 00:00:00 2001 From: Tyler Hogarth <5163494+tylerhogarth@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:44:30 +0200 Subject: [PATCH 5/6] fix(skills): stage the ORM-rename edits missed by the merge commit The merge commit ff8bbca1 described porting the Prisma ORM rename and the 0.16.0 stamp into the renamed skill, but the edits were never staged: the commit carried the pre-merge file and the working tree held the changes. This commit adds them: rawPostgres/postgres/dataContract, the /orm subpath, and library_version 0.16.0. Also reflows the readFile call in the frontmatter test to Biome's line width. Co-Authored-By: Claude Fable 5 Signed-off-by: Tyler Hogarth <5163494+tylerhogarth@users.noreply.github.com> --- scripts/skill-frontmatter.test.ts | 5 +++- skills/prisma-composer-core-concepts/SKILL.md | 26 +++++++++---------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/scripts/skill-frontmatter.test.ts b/scripts/skill-frontmatter.test.ts index de510be5..5311981a 100644 --- a/scripts/skill-frontmatter.test.ts +++ b/scripts/skill-frontmatter.test.ts @@ -91,7 +91,10 @@ describe('the shipped skill', () => { const rootVersion = JSON.parse( await readFile(path.join(repoRoot, 'package.json'), 'utf-8'), ).version; - const source = await readFile(path.join(repoRoot, 'skills/prisma-composer-core-concepts/SKILL.md'), 'utf-8'); + const source = await readFile( + path.join(repoRoot, 'skills/prisma-composer-core-concepts/SKILL.md'), + 'utf-8', + ); assert.deepEqual(readSkillFrontmatter(source), { library: '@prisma/composer', diff --git a/skills/prisma-composer-core-concepts/SKILL.md b/skills/prisma-composer-core-concepts/SKILL.md index 1a8dabe3..76dc8e67 100644 --- a/skills/prisma-composer-core-concepts/SKILL.md +++ b/skills/prisma-composer-core-concepts/SKILL.md @@ -2,8 +2,8 @@ name: prisma-composer-core-concepts metadata: library: "@prisma/composer" - library_version: "0.15.0" - version: 2026.8.26 + library_version: "0.16.0" + version: 2026.8.28 description: >- Use when deploying or managing an app that uses Prisma Composer (`@prisma/composer`): wiring its services and Modules, running it locally, @@ -42,7 +42,7 @@ app, executing nothing when imported. Three node kinds exist: | Kind | Declared with | Purpose | | --- | --- | --- | | Service | `compute()` | A running unit of your code; atomic, Composer sees only its ports | -| Resource | `postgres()`, `bucket()` | A stateful managed dependency | +| Resource | `rawPostgres()`, `bucket()` | A stateful managed dependency | | Module | `module()` | A grouping boundary; runs no code of its own, exposes typed ports | Nodes connect through **ports**: `deps` declares what a node requires, @@ -70,9 +70,9 @@ deploy; don't use the cloud to find out whether the wiring is correct. Composer itself is target-agnostic: `@prisma/composer` carries authoring, testing, and the CLI, coupled to no platform. A deploy target is an extension registered in the deploy config; `@prisma/composer-prisma-cloud` is the -Prisma Cloud target (`compute`, `postgres`, `bucket`, `envSecret`, -`envParam`, and the shared `/cron`, `/storage`, `/streams`, `/prisma-next` -modules) and the one this skill's deploy sections assume. Exactly these two +Prisma Cloud target (`compute`, `rawPostgres`, `postgres`, `bucket`, +`envSecret`, `envParam`, and the shared `/cron`, `/storage`, `/streams`, +`/orm` modules) and the one this skill's deploy sections assume. Exactly these two packages appear in an app's `package.json`. Compose an existing Module before implementing a capability yourself; wiring one in is a couple of lines. @@ -98,7 +98,7 @@ produces and the platform boots: // service.ts export default compute({ name: 'auth', - deps: { db: postgres() }, + deps: { db: rawPostgres() }, build: node({ module: import.meta.url, entry: '../dist/server.mjs' }), expose: { rpc: authContract }, }); @@ -118,7 +118,7 @@ client back from `load()`. | The value is… | Declare | Provide | Read | | --- | --- | --- | --- | -| produced by another node | `deps: { db: postgres() }` | wire at `provision()` | `load()` | +| produced by another node | `deps: { db: rawPostgres() }` | wire at `provision()` | `load()` | | anything else (config or credential) | one field of the `input` schema | bind at `provision()`: literal, `envParam()`, or `envSecret()` | `input()` | The service declares its whole incoming configuration, plain values and @@ -218,12 +218,12 @@ imports it. Two kinds of Postgres dependency: -1. **`postgres()`**: the binding is `{ url }` and the app owns its client. -2. **`pnPostgres(...)`**: a Prisma Next-typed database. `load()` returns the +1. **`rawPostgres()`**: the binding is `{ url }` and the app owns its client. +2. **`postgres(...)`**: a Prisma-ORM-typed database. `load()` returns the typed client Composer constructs from your data contract, so queries are - compile-time checked. One `pnContract`-wrapped value (emitted from + compile-time checked. One `dataContract`-wrapped value (emitted from `contract.prisma` by `prisma contract emit`) is referenced by both the - dependency end (`deps: { db: pnPostgres(catalogData) }`) and the resource + dependency end (`deps: { db: postgres(catalogData) }`) and the resource end, which also names the `prisma.config.ts` path so the deploy's migration step can find `migrations/`. @@ -392,7 +392,7 @@ provision exactly like your own: | `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` | -`bucket()` (imported alongside `postgres`) is a raw S3-compatible bucket: +`bucket()` (imported alongside `rawPostgres`) is a raw S3-compatible bucket: the dependency end receives `{ url, bucket, accessKeyId, secretAccessKey }`, shape-compatible with `/storage`'s `s3()` dependency, so a service wired to `s3()` can be rewired to a `bucket` resource unchanged. From d4ecf637b3ed399ff7c2d122957a35fcaf875f3a Mon Sep 17 00:00:00 2001 From: Tyler Hogarth <5163494+tylerhogarth@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:51:10 +0200 Subject: [PATCH 6/6] docs(skills): scope the package-count claim to Composer packages An app's package.json also carries its own dependencies and any prisma-composer-* extensions, so "exactly these two packages appear" was false. The claim is now: the only two Composer packages a basic Prisma Cloud app needs, with extensions adding their own package alongside. Co-Authored-By: Claude Fable 5 Signed-off-by: Tyler Hogarth <5163494+tylerhogarth@users.noreply.github.com> --- skills/prisma-composer-core-concepts/SKILL.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/skills/prisma-composer-core-concepts/SKILL.md b/skills/prisma-composer-core-concepts/SKILL.md index 76dc8e67..08e68d2b 100644 --- a/skills/prisma-composer-core-concepts/SKILL.md +++ b/skills/prisma-composer-core-concepts/SKILL.md @@ -72,10 +72,11 @@ testing, and the CLI, coupled to no platform. A deploy target is an extension registered in the deploy config; `@prisma/composer-prisma-cloud` is the Prisma Cloud target (`compute`, `rawPostgres`, `postgres`, `bucket`, `envSecret`, `envParam`, and the shared `/cron`, `/storage`, `/streams`, -`/orm` modules) and the one this skill's deploy sections assume. Exactly these two -packages appear in an app's `package.json`. Compose an -existing Module before implementing a capability yourself; wiring one in is a -couple of lines. +`/orm` modules) and the one this skill's deploy sections assume. These are +the only two Composer packages a basic Prisma Cloud app needs; an extension +adds its own `prisma-composer-*` package alongside them. Compose an existing +Module before implementing a capability yourself; wiring one in is a couple +of lines. Within the entry graph (everything reachable from `module.ts`), relative imports may use `./service.js` or extensionless `./service`: the CLI maps