diff --git a/.gitignore b/.gitignore index 8caf5508..5a232e1e 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,10 @@ packages/e2e-tests/.tmp-output # `makerkit deploy`/`destroy`'s generated stack file (deploy-cli.md) — regenerated every run. .makerkit/ +# `prisma-compose deploy`/`destroy`'s generated stack file — regenerated every +# run, full of machine-local absolute paths. +.prisma-compose/ + # Local clone of prisma-next used as a scaffolding reference; not part of this repo. /prisma-next/ diff --git a/examples/store/DEMO.md b/examples/store/DEMO.md new file mode 100644 index 00000000..415323dc --- /dev/null +++ b/examples/store/DEMO.md @@ -0,0 +1,88 @@ +# Standup walkthrough (~5 min) + +Order of files to open, with the one point each makes. + +## 1. The whole app — [module.ts](module.ts) + +The entire application is ~6 lines of composition. Three components, three +edges, no infrastructure config anywhere. Point at the wiring: +`orders` gets `catalog.rpc`, `storefront` gets both. + +## 2. A contract — [modules/catalog/src/contract.ts](modules/catalog/src/contract.ts) + +The edge type. arktype schemas → a typed client on the consumer side and a +type-checked handler map on the producer side. Validated at the boundary at +runtime. + +## 3. A module — [modules/catalog/src/module.ts](modules/catalog/src/module.ts) + +catalog owns its own Postgres — a **Prisma Next-typed** one. Three pieces: + +- [contract.prisma](modules/catalog/contract.prisma): the data schema. Emit + turns it into a typed contract; `migration plan` authors + [migrations/](modules/catalog/migrations), which the deploy applies before + the service ever starts — no `create table if not exists` in app code. +- [src/service.ts](modules/catalog/src/service.ts): the service declares + `deps: { db: pnPostgres(catalogData) }` and what it exposes; that's the + whole declaration. +- [src/server.ts](modules/catalog/src/server.ts): `load()` hands it the typed + client — `db.orm.public.Product.where({ id }).first()`. No SQL strings, no + row mappers, and the same contract types the resource end (the deploy + refuses to wire a service against a database with a different contract). + +## 4. The interesting edge — [modules/orders/src/module.ts](modules/orders/src/module.ts) + +orders owns a Postgres too, but declares `deps: { catalog }` as a **boundary +input** — the parent supplies any producer of `catalogContract`. In +[src/server.ts](modules/orders/src/server.ts), `placeOrder` calls +`catalog.getProduct()` — a typed async call, no URL, no fetch, no client +setup. It doesn't know or care where catalog runs. + +## 5. A shared module — cron rotating the ★ special + +[module.ts](module.ts) provisions `cron({ schedule, runner })` — the cron +module ships with the framework +(`@prisma/compose-prisma-cloud/cron`), not this app. Two points: + +- [modules/promotions/src/service.ts](modules/promotions/src/service.ts): + the app supplies only the schedule (`rotateSpecial: '30s'`) and a runner + whose handler maps the job id to `catalog.rotateSpecial()` + ([src/server.ts](modules/promotions/src/server.ts)). `serveSchedule` is + exhaustive over the schedule's ids at compile time. +- The cron module's boundary deps mirror the runner's own deps, so the root + wires `catalog: catalog.rpc` into it exactly like any other edge — a + reusable module composes the same way app modules do. + +Live proof: watch the ★ move to the next product every 30s on refresh. + +## 6. Plain app code — [modules/storefront/app/page.tsx](modules/storefront/app/page.tsx) + +A normal Next.js server component. `service.load()` hands it two typed +clients, injected by the root's wiring. The Buy button is a server action +calling `orders.placeOrder`. + +## 7. Show it live + +Deployed: run the deployed storefront URL, buy a coffee, watch it appear in +recent orders (two RPC hops + two Postgres writes behind one click). + +Fallback if the cloud misbehaves: `pnpm dev` here runs the same storefront +against in-memory fakes on loopback — same contracts, zero cloud. + +## 8. Testing story (if time) + +[page.test.tsx](modules/storefront/app/page.test.tsx): `mockService` swaps +`load()` for typed fakes — the handler maps are type-checked against the same +contracts the real modules serve. No Postgres, no server, no cloud. + +## Likely questions + +- **"What actually got deployed?"** Each compute service is a Prisma Compute + VM; each module's `postgres()` is a Prisma Postgres database. The deploy + derives the graph from the code above — same code could target another + extension pack. +- **"What's in an edge?"** At runtime: an env var (`CATALOG_URL`) the + framework injects, plus the contract's validation on both ends. +- **"Why modules and not just services?"** Boundaries. catalog's database can + never become someone else's dependency — it isn't reachable. Only exposed + ports are. diff --git a/examples/store/README.md b/examples/store/README.md new file mode 100644 index 00000000..2cb4328b --- /dev/null +++ b/examples/store/README.md @@ -0,0 +1,80 @@ +# store — Compose Coffee + +A readable example Prisma App: a Next.js storefront, a **catalog** module, an +**orders** module, and the shared **cron** module rotating the special of the +day. + +```mermaid +flowchart TB + User((User)) + + subgraph Storefront["storefront"] + SF["Next.js · Compute Service"] + end + subgraph Catalog["Module: catalog"] + CA["catalog · Compute Service"] + CDB[("Postgres — private")] + CA --- CDB + end + subgraph Orders["Module: orders"] + OR["orders · Compute Service"] + ODB[("Postgres — private")] + OR --- ODB + end + subgraph Cron["Module: cron (shared)"] + SCH["scheduler · Compute Service"] + PR["promotions runner · Compute Service"] + SCH -->|"trigger(jobId)"| PR + end + + User -->|HTTP| SF + SF -->|rpc · catalogContract| CA + SF -->|rpc · ordersContract| OR + OR -->|rpc · catalogContract| CA + PR -->|rpc · catalogContract| CA +``` + +Each module owns its own Postgres; the only edges between components are the +typed RPC contracts. The whole composition is [module.ts](module.ts). + +## What each piece shows + +- [modules/catalog](modules/catalog) — a self-contained Module: a contract + (`listProducts`/`getProduct`), a compute service, and a **Prisma + Next-typed Postgres** it owns. The data schema is + [contract.prisma](modules/catalog/contract.prisma); the deploy applies + [migrations/](modules/catalog/migrations) before the service starts, and + `load()` hands the server a typed client — queries like + `db.orm.public.Product.where({ id }).first()`, no SQL, no row mapping. + Consumers wire only the exposed `rpc` port. +- [modules/orders](modules/orders) — a Module with a **boundary input**: it + owns its (also Prisma Next-typed) Postgres but declares `deps: { catalog }`, + so whoever provisions it supplies a producer of `catalogContract`. + `placeOrder` calls catalog to price the order at placement time. +- [modules/storefront](modules/storefront) — a real Next.js app. The page + calls both typed clients; the Buy button is a server action that places an + order. +- [modules/promotions](modules/promotions) + the shared + `@prisma/compose-prisma-cloud/cron` module — **composition of a shared + module**. promotions defines the schedule + (`rotateSpecial: '30s'`) and the runner that maps the job id to + `catalog.rotateSpecial()`; the root provisions `cron({ schedule, runner })` + and wires `catalog.rpc` into its boundary like any other edge. The ★ + special on the menu moves every 30 seconds. + +## Run it locally (no cloud) + +```sh +pnpm dev # from examples/store +``` + +Serves in-memory fakes of catalog and orders on loopback ports and runs +`next dev` against them — the same fakes the unit test injects via +`mockService` ([page.test.tsx](modules/storefront/app/page.test.tsx)). + +## Deploy + +```sh +pnpm deploy # needs .env at the repo root (see examples/storefront-auth) +pnpm destroy +``` diff --git a/examples/store/module.ts b/examples/store/module.ts new file mode 100644 index 00000000..dfa04375 --- /dev/null +++ b/examples/store/module.ts @@ -0,0 +1,33 @@ +import { module } from '@prisma/compose'; +import { cron } from '@prisma/compose-prisma-cloud/cron'; +import catalogModule from '@store/catalog'; +import ordersModule from '@store/orders'; +import promotionsService, { schedule } from '@store/promotions'; +import storefrontService from '@store/storefront'; + +/** + * The store app: four components, four edges. + * + * storefront ──rpc──▶ catalog (browse products) + * storefront ──rpc──▶ orders (place + list orders) + * orders ──rpc──▶ catalog (price an order at placement time) + * cron ──rpc──▶ catalog (rotate the special of the day, every 30s) + * + * catalog and orders each own their own Postgres internally — the root never + * sees it. All it wires are the exposed, typed rpc ports. + * + * `cron` is a SHARED module (@prisma/compose-prisma-cloud/cron), not app + * code: it takes our schedule and our promotions runner, and its boundary + * deps mirror the runner's own — so the root wires `catalog` into it exactly + * as it would for any other consumer of that contract. + */ +export default module('store', ({ provision }) => { + const catalog = provision(catalogModule); + const orders = provision(ordersModule, { deps: { catalog: catalog.rpc } }); + + provision(storefrontService, { deps: { catalog: catalog.rpc, orders: orders.rpc } }); + + provision(cron({ schedule, runner: promotionsService }), { + deps: { catalog: catalog.rpc }, + }); +}); diff --git a/examples/store/modules/catalog/contract.d.ts b/examples/store/modules/catalog/contract.d.ts new file mode 100644 index 00000000..e747eddf --- /dev/null +++ b/examples/store/modules/catalog/contract.d.ts @@ -0,0 +1,232 @@ +// ⚠️ GENERATED FILE - DO NOT EDIT +// This file is automatically generated by 'prisma-next contract emit'. +// To regenerate, run: prisma-next contract emit +import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; +import type { + Bit, + Char, + CodecTypes as PgTypes, + Interval, + JsonValue, + Numeric, + Time, + Timestamp, + Timestamptz, + Timetz, + VarBit, + Varchar, +} from '@prisma-next/target-postgres/codec-types'; + +import type { + ContractWithTypeMaps, + TypeMaps as TypeMapsType, +} from '@prisma-next/sql-contract/types'; +import type { + Contract as ContractType, + ExecutionHashBase, + NamespaceId, + ProfileHashBase, + StorageHashBase, +} from '@prisma-next/contract/types'; + +export type StorageHash = + StorageHashBase<'sha256:454ca6e7e026e7ea3664b651ff39b82c327191d7cb6e55f5314b157d223305e9'>; +export type ExecutionHash = ExecutionHashBase; +export type ProfileHash = + ProfileHashBase<'sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb'>; + +export type CodecTypes = PgTypes; +export type LaneCodecTypes = CodecTypes; +export type QueryOperationTypes = PgAdapterQueryOps; +type DefaultLiteralValue = CodecId extends keyof CodecTypes + ? CodecTypes[CodecId]['output'] + : _Encoded; + +export type FieldOutputTypes = { + readonly public: { + readonly Product: { + readonly id: CodecTypes['pg/text@1']['output']; + readonly name: CodecTypes['pg/text@1']['output']; + readonly description: CodecTypes['pg/text@1']['output']; + readonly priceCents: CodecTypes['pg/int4@1']['output']; + }; + readonly Special: { + readonly id: CodecTypes['pg/int4@1']['output']; + readonly productId: CodecTypes['pg/text@1']['output']; + }; + }; +}; +export type FieldInputTypes = { + readonly public: { + readonly Product: { + readonly id: CodecTypes['pg/text@1']['input']; + readonly name: CodecTypes['pg/text@1']['input']; + readonly description: CodecTypes['pg/text@1']['input']; + readonly priceCents: CodecTypes['pg/int4@1']['input']; + }; + readonly Special: { + readonly id: CodecTypes['pg/int4@1']['input']; + readonly productId: CodecTypes['pg/text@1']['input']; + }; + }; +}; +export type TypeMaps = TypeMapsType< + CodecTypes, + QueryOperationTypes, + FieldOutputTypes, + FieldInputTypes +>; + +type ContractBase = Omit< + ContractType<{ + readonly namespaces: { + readonly public: { + readonly id: 'public'; + readonly kind: 'postgres-schema'; + readonly entries: { + readonly table: { + readonly product: { + columns: { + readonly id: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly name: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly description: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly priceCents: { + readonly nativeType: 'int4'; + readonly codecId: 'pg/int4@1'; + readonly nullable: false; + }; + }; + primaryKey: { readonly columns: readonly ['id'] }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly []; + }; + readonly special: { + columns: { + readonly id: { + readonly nativeType: 'int4'; + readonly codecId: 'pg/int4@1'; + readonly nullable: false; + }; + readonly productId: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + }; + primaryKey: { readonly columns: readonly ['id'] }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly []; + }; + }; + }; + }; + }; + readonly storageHash: StorageHash; + }>, + 'roots' | 'domain' +> & { + readonly target: 'postgres'; + readonly targetFamily: 'sql'; + readonly roots: { + readonly product: { readonly namespace: 'public' & NamespaceId; readonly model: 'Product' }; + readonly special: { readonly namespace: 'public' & NamespaceId; readonly model: 'Special' }; + }; + readonly domain: { + readonly namespaces: { + readonly public: { + readonly models: { + readonly Product: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly name: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly description: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly priceCents: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'product'; + readonly namespaceId: 'public'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly name: { readonly column: 'name' }; + readonly description: { readonly column: 'description' }; + readonly priceCents: { readonly column: 'priceCents' }; + }; + }; + }; + readonly Special: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + readonly productId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'special'; + readonly namespaceId: 'public'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly productId: { readonly column: 'productId' }; + }; + }; + }; + }; + }; + }; + }; + readonly capabilities: { + readonly postgres: { + readonly distinctOn: true; + readonly jsonAgg: true; + readonly lateral: true; + readonly limit: true; + readonly orderBy: true; + readonly returning: true; + }; + readonly sql: { + readonly defaultInInsert: true; + readonly enums: true; + readonly lateral: true; + readonly returning: true; + }; + }; + readonly extensionPacks: {}; + readonly meta: {}; + + readonly profileHash: ProfileHash; +}; + +export type Contract = ContractWithTypeMaps; + +export type Namespaces = Contract['storage']['namespaces']; diff --git a/examples/store/modules/catalog/contract.json b/examples/store/modules/catalog/contract.json new file mode 100644 index 00000000..5bd83727 --- /dev/null +++ b/examples/store/modules/catalog/contract.json @@ -0,0 +1,192 @@ +{ + "schemaVersion": "1", + "targetFamily": "sql", + "target": "postgres", + "profileHash": "sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb", + "roots": { + "product": { + "model": "Product", + "namespace": "public" + }, + "special": { + "model": "Special", + "namespace": "public" + } + }, + "domain": { + "namespaces": { + "public": { + "models": { + "Product": { + "fields": { + "description": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "id": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "name": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "priceCents": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "fields": { + "description": { + "column": "description" + }, + "id": { + "column": "id" + }, + "name": { + "column": "name" + }, + "priceCents": { + "column": "priceCents" + } + }, + "namespaceId": "public", + "table": "product" + } + }, + "Special": { + "fields": { + "id": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + }, + "productId": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "fields": { + "id": { + "column": "id" + }, + "productId": { + "column": "productId" + } + }, + "namespaceId": "public", + "table": "special" + } + } + } + } + } + }, + "storage": { + "namespaces": { + "public": { + "entries": { + "table": { + "product": { + "columns": { + "description": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "id": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "name": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "priceCents": { + "codecId": "pg/int4@1", + "nativeType": "int4", + "nullable": false + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": ["id"] + }, + "uniques": [] + }, + "special": { + "columns": { + "id": { + "codecId": "pg/int4@1", + "nativeType": "int4", + "nullable": false + }, + "productId": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": ["id"] + }, + "uniques": [] + } + } + }, + "id": "public", + "kind": "postgres-schema" + } + }, + "storageHash": "sha256:454ca6e7e026e7ea3664b651ff39b82c327191d7cb6e55f5314b157d223305e9" + }, + "capabilities": { + "postgres": { + "distinctOn": true, + "jsonAgg": true, + "lateral": true, + "limit": true, + "orderBy": true, + "returning": true + }, + "sql": { + "defaultInInsert": true, + "enums": true, + "lateral": true, + "returning": true + } + }, + "extensionPacks": {}, + "meta": {}, + "_generated": { + "warning": "⚠️ GENERATED FILE - DO NOT EDIT", + "message": "This file is automatically generated by \"prisma-next contract emit\".", + "regenerate": "To regenerate, run: prisma-next contract emit" + } +} diff --git a/examples/store/modules/catalog/contract.prisma b/examples/store/modules/catalog/contract.prisma new file mode 100644 index 00000000..b136ddd3 --- /dev/null +++ b/examples/store/modules/catalog/contract.prisma @@ -0,0 +1,16 @@ +// use prisma-next +// +// catalog's data contract: the menu, and a single-row Special pointing at the +// current special of the day (the promotions cron job advances it). + +model Product { + id String @id + name String + description String + priceCents Int +} + +model Special { + id Int @id + productId String +} diff --git a/examples/store/modules/catalog/migrations/app/20260713T1212_init/end-contract.d.ts b/examples/store/modules/catalog/migrations/app/20260713T1212_init/end-contract.d.ts new file mode 100644 index 00000000..e747eddf --- /dev/null +++ b/examples/store/modules/catalog/migrations/app/20260713T1212_init/end-contract.d.ts @@ -0,0 +1,232 @@ +// ⚠️ GENERATED FILE - DO NOT EDIT +// This file is automatically generated by 'prisma-next contract emit'. +// To regenerate, run: prisma-next contract emit +import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; +import type { + Bit, + Char, + CodecTypes as PgTypes, + Interval, + JsonValue, + Numeric, + Time, + Timestamp, + Timestamptz, + Timetz, + VarBit, + Varchar, +} from '@prisma-next/target-postgres/codec-types'; + +import type { + ContractWithTypeMaps, + TypeMaps as TypeMapsType, +} from '@prisma-next/sql-contract/types'; +import type { + Contract as ContractType, + ExecutionHashBase, + NamespaceId, + ProfileHashBase, + StorageHashBase, +} from '@prisma-next/contract/types'; + +export type StorageHash = + StorageHashBase<'sha256:454ca6e7e026e7ea3664b651ff39b82c327191d7cb6e55f5314b157d223305e9'>; +export type ExecutionHash = ExecutionHashBase; +export type ProfileHash = + ProfileHashBase<'sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb'>; + +export type CodecTypes = PgTypes; +export type LaneCodecTypes = CodecTypes; +export type QueryOperationTypes = PgAdapterQueryOps; +type DefaultLiteralValue = CodecId extends keyof CodecTypes + ? CodecTypes[CodecId]['output'] + : _Encoded; + +export type FieldOutputTypes = { + readonly public: { + readonly Product: { + readonly id: CodecTypes['pg/text@1']['output']; + readonly name: CodecTypes['pg/text@1']['output']; + readonly description: CodecTypes['pg/text@1']['output']; + readonly priceCents: CodecTypes['pg/int4@1']['output']; + }; + readonly Special: { + readonly id: CodecTypes['pg/int4@1']['output']; + readonly productId: CodecTypes['pg/text@1']['output']; + }; + }; +}; +export type FieldInputTypes = { + readonly public: { + readonly Product: { + readonly id: CodecTypes['pg/text@1']['input']; + readonly name: CodecTypes['pg/text@1']['input']; + readonly description: CodecTypes['pg/text@1']['input']; + readonly priceCents: CodecTypes['pg/int4@1']['input']; + }; + readonly Special: { + readonly id: CodecTypes['pg/int4@1']['input']; + readonly productId: CodecTypes['pg/text@1']['input']; + }; + }; +}; +export type TypeMaps = TypeMapsType< + CodecTypes, + QueryOperationTypes, + FieldOutputTypes, + FieldInputTypes +>; + +type ContractBase = Omit< + ContractType<{ + readonly namespaces: { + readonly public: { + readonly id: 'public'; + readonly kind: 'postgres-schema'; + readonly entries: { + readonly table: { + readonly product: { + columns: { + readonly id: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly name: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly description: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly priceCents: { + readonly nativeType: 'int4'; + readonly codecId: 'pg/int4@1'; + readonly nullable: false; + }; + }; + primaryKey: { readonly columns: readonly ['id'] }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly []; + }; + readonly special: { + columns: { + readonly id: { + readonly nativeType: 'int4'; + readonly codecId: 'pg/int4@1'; + readonly nullable: false; + }; + readonly productId: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + }; + primaryKey: { readonly columns: readonly ['id'] }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly []; + }; + }; + }; + }; + }; + readonly storageHash: StorageHash; + }>, + 'roots' | 'domain' +> & { + readonly target: 'postgres'; + readonly targetFamily: 'sql'; + readonly roots: { + readonly product: { readonly namespace: 'public' & NamespaceId; readonly model: 'Product' }; + readonly special: { readonly namespace: 'public' & NamespaceId; readonly model: 'Special' }; + }; + readonly domain: { + readonly namespaces: { + readonly public: { + readonly models: { + readonly Product: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly name: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly description: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly priceCents: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'product'; + readonly namespaceId: 'public'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly name: { readonly column: 'name' }; + readonly description: { readonly column: 'description' }; + readonly priceCents: { readonly column: 'priceCents' }; + }; + }; + }; + readonly Special: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + readonly productId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'special'; + readonly namespaceId: 'public'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly productId: { readonly column: 'productId' }; + }; + }; + }; + }; + }; + }; + }; + readonly capabilities: { + readonly postgres: { + readonly distinctOn: true; + readonly jsonAgg: true; + readonly lateral: true; + readonly limit: true; + readonly orderBy: true; + readonly returning: true; + }; + readonly sql: { + readonly defaultInInsert: true; + readonly enums: true; + readonly lateral: true; + readonly returning: true; + }; + }; + readonly extensionPacks: {}; + readonly meta: {}; + + readonly profileHash: ProfileHash; +}; + +export type Contract = ContractWithTypeMaps; + +export type Namespaces = Contract['storage']['namespaces']; diff --git a/examples/store/modules/catalog/migrations/app/20260713T1212_init/end-contract.json b/examples/store/modules/catalog/migrations/app/20260713T1212_init/end-contract.json new file mode 100644 index 00000000..85ebd87b --- /dev/null +++ b/examples/store/modules/catalog/migrations/app/20260713T1212_init/end-contract.json @@ -0,0 +1,196 @@ +{ + "schemaVersion": "1", + "targetFamily": "sql", + "target": "postgres", + "profileHash": "sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb", + "roots": { + "product": { + "model": "Product", + "namespace": "public" + }, + "special": { + "model": "Special", + "namespace": "public" + } + }, + "domain": { + "namespaces": { + "public": { + "models": { + "Product": { + "fields": { + "description": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "id": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "name": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "priceCents": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "fields": { + "description": { + "column": "description" + }, + "id": { + "column": "id" + }, + "name": { + "column": "name" + }, + "priceCents": { + "column": "priceCents" + } + }, + "namespaceId": "public", + "table": "product" + } + }, + "Special": { + "fields": { + "id": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + }, + "productId": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "fields": { + "id": { + "column": "id" + }, + "productId": { + "column": "productId" + } + }, + "namespaceId": "public", + "table": "special" + } + } + } + } + } + }, + "storage": { + "namespaces": { + "public": { + "entries": { + "table": { + "product": { + "columns": { + "description": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "id": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "name": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "priceCents": { + "codecId": "pg/int4@1", + "nativeType": "int4", + "nullable": false + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": [ + "id" + ] + }, + "uniques": [] + }, + "special": { + "columns": { + "id": { + "codecId": "pg/int4@1", + "nativeType": "int4", + "nullable": false + }, + "productId": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": [ + "id" + ] + }, + "uniques": [] + } + } + }, + "id": "public", + "kind": "postgres-schema" + } + }, + "storageHash": "sha256:454ca6e7e026e7ea3664b651ff39b82c327191d7cb6e55f5314b157d223305e9" + }, + "capabilities": { + "postgres": { + "distinctOn": true, + "jsonAgg": true, + "lateral": true, + "limit": true, + "orderBy": true, + "returning": true + }, + "sql": { + "defaultInInsert": true, + "enums": true, + "lateral": true, + "returning": true + } + }, + "extensionPacks": {}, + "meta": {}, + "_generated": { + "warning": "⚠️ GENERATED FILE - DO NOT EDIT", + "message": "This file is automatically generated by \"prisma-next contract emit\".", + "regenerate": "To regenerate, run: prisma-next contract emit" + } +} \ No newline at end of file diff --git a/examples/store/modules/catalog/migrations/app/20260713T1212_init/migration.json b/examples/store/modules/catalog/migrations/app/20260713T1212_init/migration.json new file mode 100644 index 00000000..8d8184f0 --- /dev/null +++ b/examples/store/modules/catalog/migrations/app/20260713T1212_init/migration.json @@ -0,0 +1,7 @@ +{ + "from": null, + "to": "sha256:454ca6e7e026e7ea3664b651ff39b82c327191d7cb6e55f5314b157d223305e9", + "providedInvariants": [], + "createdAt": "2026-07-13T12:12:35.384Z", + "migrationHash": "sha256:cbf4595c4fd6d9d71295956fb7ed98c58166e1c0fc1bfd1b3de298a5e6bd489c" +} \ No newline at end of file diff --git a/examples/store/modules/catalog/migrations/app/20260713T1212_init/migration.ts b/examples/store/modules/catalog/migrations/app/20260713T1212_init/migration.ts new file mode 100755 index 00000000..7a27663b --- /dev/null +++ b/examples/store/modules/catalog/migrations/app/20260713T1212_init/migration.ts @@ -0,0 +1,38 @@ +#!/usr/bin/env -S bun +import { Migration, MigrationCLI, col, primaryKey } from '@prisma-next/postgres/migration'; + +export default class M extends Migration { + override describe() { + return { + from: null, + to: 'sha256:454ca6e7e026e7ea3664b651ff39b82c327191d7cb6e55f5314b157d223305e9', + }; + } + + override get operations() { + return [ + this.createTable({ + schema: 'public', + table: 'product', + columns: [ + col('description', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }), + col('id', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }), + col('name', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }), + col('priceCents', 'int4', { notNull: true, codecRef: { codecId: 'pg/int4@1' } }), + ], + constraints: [primaryKey(['id'])], + }), + this.createTable({ + schema: 'public', + table: 'special', + columns: [ + col('id', 'int4', { notNull: true, codecRef: { codecId: 'pg/int4@1' } }), + col('productId', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }), + ], + constraints: [primaryKey(['id'])], + }), + ]; + } +} + +MigrationCLI.run(import.meta.url, M); diff --git a/examples/store/modules/catalog/migrations/app/20260713T1212_init/ops.json b/examples/store/modules/catalog/migrations/app/20260713T1212_init/ops.json new file mode 100644 index 00000000..2bd06f95 --- /dev/null +++ b/examples/store/modules/catalog/migrations/app/20260713T1212_init/ops.json @@ -0,0 +1,80 @@ +[ + { + "id": "table.product", + "label": "Create table \"product\"", + "summary": "Creates table \"product\"", + "operationClass": "additive", + "target": { + "id": "postgres", + "details": { + "schema": "public", + "objectType": "table", + "name": "product" + } + }, + "precheck": [ + { + "description": "ensure table \"product\" does not exist", + "sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"", + "params": [ + "\"public\".\"product\"" + ] + } + ], + "execute": [ + { + "description": "create table \"product\"", + "sql": "CREATE TABLE \"public\".\"product\" (\n \"description\" text NOT NULL,\n \"id\" text NOT NULL,\n \"name\" text NOT NULL,\n \"priceCents\" int4 NOT NULL,\n PRIMARY KEY (\"id\")\n)", + "params": [] + } + ], + "postcheck": [ + { + "description": "verify table \"product\" exists", + "sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"", + "params": [ + "\"public\".\"product\"" + ] + } + ] + }, + { + "id": "table.special", + "label": "Create table \"special\"", + "summary": "Creates table \"special\"", + "operationClass": "additive", + "target": { + "id": "postgres", + "details": { + "schema": "public", + "objectType": "table", + "name": "special" + } + }, + "precheck": [ + { + "description": "ensure table \"special\" does not exist", + "sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"", + "params": [ + "\"public\".\"special\"" + ] + } + ], + "execute": [ + { + "description": "create table \"special\"", + "sql": "CREATE TABLE \"public\".\"special\" (\n \"id\" int4 NOT NULL,\n \"productId\" text NOT NULL,\n PRIMARY KEY (\"id\")\n)", + "params": [] + } + ], + "postcheck": [ + { + "description": "verify table \"special\" exists", + "sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"", + "params": [ + "\"public\".\"special\"" + ] + } + ] + } +] \ No newline at end of file diff --git a/examples/store/modules/catalog/package.json b/examples/store/modules/catalog/package.json new file mode 100644 index 00000000..e3632786 --- /dev/null +++ b/examples/store/modules/catalog/package.json @@ -0,0 +1,32 @@ +{ + "name": "@store/catalog", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/module.ts", + "./contract": "./src/contract.ts", + "./fake": "./testing/fake.ts" + }, + "scripts": { + "dev": "bun run scripts/dev.ts", + "build": "tsdown", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@prisma-next/postgres": "0.14.0", + "arktype": "^2.2.3", + "pg": "8.21.0" + }, + "peerDependencies": { + "@prisma/compose": "workspace:0.1.0", + "@prisma/compose-prisma-cloud": "workspace:0.1.0" + }, + "devDependencies": { + "@prisma/compose": "workspace:0.1.0", + "@prisma/compose-prisma-cloud": "workspace:0.1.0", + "@types/bun": "^1.3.13", + "tsdown": "^0.22.3", + "typescript": "^6.0.3" + } +} diff --git a/examples/store/modules/catalog/prisma-next.config.ts b/examples/store/modules/catalog/prisma-next.config.ts new file mode 100644 index 00000000..13737a48 --- /dev/null +++ b/examples/store/modules/catalog/prisma-next.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from '@prisma-next/postgres/config'; + +// Anchors the contract source and migrations/ on disk. The deploy lowering +// loads it (by path, from the pnPostgres resource's `config`) to find the +// migrations — the app build never imports it. `db.connection` is dead +// weight: the framework injects the URL at hydrate (no-globals). +export default defineConfig({ + contract: './contract.prisma', + db: { connection: 'postgres://localhost:5432/placeholder' }, +}); diff --git a/examples/store/modules/catalog/scripts/dev.ts b/examples/store/modules/catalog/scripts/dev.ts new file mode 100644 index 00000000..2f9a2606 --- /dev/null +++ b/examples/store/modules/catalog/scripts/dev.ts @@ -0,0 +1,3 @@ +// Local dev: run the server directly. It calls service.load(), which reads +// DB_URL and PORT from your environment — set them before running. +import '../src/server.ts'; diff --git a/examples/store/modules/catalog/src/contract.ts b/examples/store/modules/catalog/src/contract.ts new file mode 100644 index 00000000..29da717b --- /dev/null +++ b/examples/store/modules/catalog/src/contract.ts @@ -0,0 +1,35 @@ +/** + * catalog's public RPC contract. It lives with the module that owns it; a + * consumer imports it and depends on it via `rpc(catalogContract)`, getting + * back a typed client. + */ +import { contract, rpc } from '@prisma/compose/rpc'; +import { type } from 'arktype'; + +export const product = type({ + id: 'string', + name: 'string', + description: 'string', + priceCents: 'number', +}); + +export type Product = typeof product.infer; + +export const catalogContract = contract({ + listProducts: rpc({ + input: type({}), + output: type({ products: product.array() }), + }), + getProduct: rpc({ + input: type({ id: 'string' }), + output: type({ product: product.or('null') }), + }), + getSpecial: rpc({ + input: type({}), + output: type({ product: product.or('null') }), + }), + rotateSpecial: rpc({ + input: type({}), + output: type({ product: product.or('null') }), + }), +}); diff --git a/examples/store/modules/catalog/src/data.ts b/examples/store/modules/catalog/src/data.ts new file mode 100644 index 00000000..27c2274b --- /dev/null +++ b/examples/store/modules/catalog/src/data.ts @@ -0,0 +1,12 @@ +/** + * catalog's Prisma Next data contract wrapped into the framework's + * `prisma-next` kind — the ONE value both ends reference: the resource end + * (`pnPostgres({ name, contract, config })` in module.ts) and the dependency + * end (`pnPostgres(catalogData)` in service.ts). Emitted from contract.prisma + * by `prisma-next contract emit`. + */ +import { pnContract } from '@prisma/compose-prisma-cloud/prisma-next'; +import type { Contract } from '../contract.d.ts'; +import contractJson from '../contract.json' with { type: 'json' }; + +export const catalogData = pnContract(contractJson); diff --git a/examples/store/modules/catalog/src/module.ts b/examples/store/modules/catalog/src/module.ts new file mode 100644 index 00000000..cbc33b3f --- /dev/null +++ b/examples/store/modules/catalog/src/module.ts @@ -0,0 +1,26 @@ +/** + * The catalog Module: a reusable unit that owns its own Postgres — a Prisma + * Next-typed one (ADR-0022). The resource carries two doors: `contract` + * (types + wires the resource, gives the deploy its target storageHash) and + * `config` (the prisma-next.config.ts PATH the deploy's migration step loads + * to find migrations/ — never imported by the app build). A consumer wires + * only the exposed rpc contract — it never sees the database. + * + * The database provision id is "database" (the Connection API rejects names + * shorter than 3 characters); the service keeps an explicit "service" id so + * it doesn't read as "catalog.catalog". + */ +import { fileURLToPath } from 'node:url'; +import { module } from '@prisma/compose'; +import { pnPostgres } from '@prisma/compose-prisma-cloud/prisma-next'; +import { catalogContract } from './contract.ts'; +import { catalogData } from './data.ts'; +import catalogService from './service.ts'; + +const config = fileURLToPath(new URL('../prisma-next.config.ts', import.meta.url)); + +export default module('catalog', { expose: { rpc: catalogContract } }, ({ provision }) => { + const db = provision(pnPostgres({ name: 'database', contract: catalogData, config })); + const service = provision(catalogService, { id: 'service', deps: { db } }); + return { rpc: service.rpc }; +}); diff --git a/examples/store/modules/catalog/src/server.ts b/examples/store/modules/catalog/src/server.ts new file mode 100644 index 00000000..6dd2e5cb --- /dev/null +++ b/examples/store/modules/catalog/src/server.ts @@ -0,0 +1,78 @@ +import { serve } from '@prisma/compose/rpc'; +import type { Product } from './contract.ts'; +import service from './service.ts'; + +// load() hydrates `db` into the typed Prisma Next client (ADR-0022) — no SQL, +// no row mapping; queries are typed by contract.prisma's emitted contract. +const { db } = service.load(); +const { port } = service.config(); + +// A Prisma Postgres direct connection is dropped when it idles / the service +// scales to zero; the lazy pool reconnects on the next query. Surface those +// as logs, not an uncaught crash into a 502 restart loop. +process.on('uncaughtException', (err) => console.error('uncaughtException', err)); +process.on('unhandledRejection', (err) => console.error('unhandledRejection', err)); + +const SEED: Product[] = [ + { + id: 'espresso', + name: 'Espresso', + description: 'A double shot, pulled short.', + priceCents: 350, + }, + { + id: 'flat-white', + name: 'Flat White', + description: 'Silky microfoam over a double shot.', + priceCents: 450, + }, + { + id: 'cold-brew', + name: 'Cold Brew', + description: 'Steeped 18 hours, served over ice.', + priceCents: 500, + }, + { id: 'croissant', name: 'Croissant', description: 'Baked every morning.', priceCents: 400 }, +]; + +// Idempotent boot seed. The schema itself is NOT created here — the deploy's +// migration step applied migrations/ before this service ever started. +for (const p of SEED) { + await db.orm.public.Product.upsert({ create: p, update: {} }); +} +await db.orm.public.Special.upsert({ create: { id: 1, productId: SEED[0].id }, update: {} }); + +const handler = serve(service, { + rpc: { + listProducts: async () => ({ + products: await db.orm.public.Product.orderBy((p) => p.name.asc()).all(), + }), + getProduct: async ({ id }) => ({ + product: (await db.orm.public.Product.where({ id }).first()) ?? null, + }), + getSpecial: async () => { + const special = await db.orm.public.Special.where({ id: 1 }).first(); + if (!special) return { product: null }; + const product = await db.orm.public.Product.where({ id: special.productId }).first(); + return { product: product ?? null }; + }, + rotateSpecial: async () => { + const products = await db.orm.public.Product.orderBy((p) => p.name.asc()).all(); + if (products.length === 0) return { product: null }; + + const special = await db.orm.public.Special.where({ id: 1 }).first(); + const currentIdx = products.findIndex((p) => p.id === special?.productId); + const next = products[(currentIdx + 1) % products.length]; + await db.orm.public.Special.upsert({ + create: { id: 1, productId: next.id }, + update: { productId: next.id }, + }); + return { product: next }; + }, + }, +}); +export default handler; + +// Bind all interfaces — Compute routes external HTTP to the VM, so a +// loopback-only listener would be unreachable. +Bun.serve({ port, hostname: '0.0.0.0', fetch: handler }); diff --git a/examples/store/modules/catalog/src/service.ts b/examples/store/modules/catalog/src/service.ts new file mode 100644 index 00000000..da99039b --- /dev/null +++ b/examples/store/modules/catalog/src/service.ts @@ -0,0 +1,17 @@ +import node from '@prisma/compose/node'; +import { compute } from '@prisma/compose-prisma-cloud'; +import { pnPostgres } from '@prisma/compose-prisma-cloud/prisma-next'; +import { catalogContract } from './contract.ts'; +import { catalogData } from './data.ts'; + +// The `db` dependency is Prisma Next-typed: `load()` returns the typed +// client the framework constructs from the contract + the injected URL +// (ADR-0022) — server.ts queries `db.orm.public.Product` directly. +export default compute({ + name: 'catalog', + deps: { + db: pnPostgres(catalogData), + }, + build: node({ module: import.meta.url, entry: '../dist/server.mjs' }), + expose: { rpc: catalogContract }, +}); diff --git a/examples/store/modules/catalog/testing/fake.ts b/examples/store/modules/catalog/testing/fake.ts new file mode 100644 index 00000000..f975bf02 --- /dev/null +++ b/examples/store/modules/catalog/testing/fake.ts @@ -0,0 +1,38 @@ +/** + * An in-memory catalog for TESTING a module that depends on it — no Postgres, + * no deploy. It serves the real `catalogContract`, so its handler map is + * type-checked against the same contract the real catalog exposes. Test-only, + * deliberately outside `src/`, so it never rides into the deployed artifact. + */ +import node from '@prisma/compose/node'; +import { serve } from '@prisma/compose/rpc'; +import { compute } from '@prisma/compose-prisma-cloud'; +import { catalogContract, type Product } from '../src/contract.ts'; + +export const FAKE_PRODUCTS: Product[] = [ + { id: 'espresso', name: 'Espresso', description: 'A double shot.', priceCents: 350 }, + { id: 'croissant', name: 'Croissant', description: 'Baked every morning.', priceCents: 400 }, +]; + +const fakeCatalog = compute({ + name: 'catalog-fake', + deps: {}, + build: node({ module: import.meta.url, entry: 'fake.ts' }), + expose: { rpc: catalogContract }, +}); + +let specialIdx = 0; + +export default serve(fakeCatalog, { + rpc: { + listProducts: async () => ({ products: FAKE_PRODUCTS }), + getProduct: async ({ id }) => ({ + product: FAKE_PRODUCTS.find((p) => p.id === id) ?? null, + }), + getSpecial: async () => ({ product: FAKE_PRODUCTS[specialIdx] }), + rotateSpecial: async () => { + specialIdx = (specialIdx + 1) % FAKE_PRODUCTS.length; + return { product: FAKE_PRODUCTS[specialIdx] }; + }, + }, +}); diff --git a/examples/store/modules/catalog/tsconfig.json b/examples/store/modules/catalog/tsconfig.json new file mode 100644 index 00000000..66ddc2c7 --- /dev/null +++ b/examples/store/modules/catalog/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "Preserve", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": false, + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "types": ["bun"] + }, + "include": ["src", "contract.d.ts"] +} diff --git a/examples/store/modules/catalog/tsdown.config.ts b/examples/store/modules/catalog/tsdown.config.ts new file mode 100644 index 00000000..2e429471 --- /dev/null +++ b/examples/store/modules/catalog/tsdown.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsdown'; + +// Build only the runnable (src/server.ts). @prisma/* and arktype are inlined — +// node_modules isn't shipped; `bun` is a Compute runtime built-in. +export default defineConfig({ + entry: { server: 'src/server.ts' }, + outDir: 'dist', + format: 'esm', + platform: 'node', + external: ['bun'], + noExternal: [/^@prisma\//, /^@prisma-next\//, /^arktype/, /^pg$/, /^pg-/, /^pathe$/], + dts: false, + sourcemap: false, + clean: true, +}); diff --git a/examples/store/modules/orders/contract.d.ts b/examples/store/modules/orders/contract.d.ts new file mode 100644 index 00000000..5d77d6df --- /dev/null +++ b/examples/store/modules/orders/contract.d.ts @@ -0,0 +1,230 @@ +// ⚠️ GENERATED FILE - DO NOT EDIT +// This file is automatically generated by 'prisma-next contract emit'. +// To regenerate, run: prisma-next contract emit +import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; +import type { + Bit, + Char, + CodecTypes as PgTypes, + Interval, + JsonValue, + Numeric, + Time, + Timestamp, + Timestamptz, + Timetz, + VarBit, + Varchar, +} from '@prisma-next/target-postgres/codec-types'; + +import type { + ContractWithTypeMaps, + TypeMaps as TypeMapsType, +} from '@prisma-next/sql-contract/types'; +import type { + Contract as ContractType, + ExecutionHashBase, + NamespaceId, + ProfileHashBase, + StorageHashBase, +} from '@prisma-next/contract/types'; + +export type StorageHash = + StorageHashBase<'sha256:c02a1b8e5e4db4eb71986673487f007e103ac093f5af4d5f4c6bb5b4ef84b5ce'>; +export type ExecutionHash = + ExecutionHashBase<'sha256:8e2d88824468b3baf7490ecbfc7147357b480cb98c89fab4b471b1300cec0f00'>; +export type ProfileHash = + ProfileHashBase<'sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb'>; + +export type CodecTypes = PgTypes; +export type LaneCodecTypes = CodecTypes; +export type QueryOperationTypes = PgAdapterQueryOps; +type DefaultLiteralValue = CodecId extends keyof CodecTypes + ? CodecTypes[CodecId]['output'] + : _Encoded; + +export type FieldOutputTypes = { + readonly public: { + readonly Order: { + readonly id: Char<36>; + readonly productId: CodecTypes['pg/text@1']['output']; + readonly productName: CodecTypes['pg/text@1']['output']; + readonly quantity: CodecTypes['pg/int4@1']['output']; + readonly totalCents: CodecTypes['pg/int4@1']['output']; + readonly placedAt: CodecTypes['pg/timestamptz@1']['output']; + }; + }; +}; +export type FieldInputTypes = { + readonly public: { + readonly Order: { + readonly id: CodecTypes['sql/char@1']['input']; + readonly productId: CodecTypes['pg/text@1']['input']; + readonly productName: CodecTypes['pg/text@1']['input']; + readonly quantity: CodecTypes['pg/int4@1']['input']; + readonly totalCents: CodecTypes['pg/int4@1']['input']; + readonly placedAt: CodecTypes['pg/timestamptz@1']['input']; + }; + }; +}; +export type TypeMaps = TypeMapsType< + CodecTypes, + QueryOperationTypes, + FieldOutputTypes, + FieldInputTypes +>; + +type ContractBase = Omit< + ContractType<{ + readonly namespaces: { + readonly public: { + readonly id: 'public'; + readonly kind: 'postgres-schema'; + readonly entries: { + readonly table: { + readonly order: { + columns: { + readonly id: { + readonly nativeType: 'character'; + readonly codecId: 'sql/char@1'; + readonly nullable: false; + readonly typeParams: { readonly length: 36 }; + }; + readonly productId: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly productName: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly quantity: { + readonly nativeType: 'int4'; + readonly codecId: 'pg/int4@1'; + readonly nullable: false; + }; + readonly totalCents: { + readonly nativeType: 'int4'; + readonly codecId: 'pg/int4@1'; + readonly nullable: false; + }; + readonly placedAt: { + readonly nativeType: 'timestamptz'; + readonly codecId: 'pg/timestamptz@1'; + readonly nullable: false; + readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + }; + }; + primaryKey: { readonly columns: readonly ['id'] }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly []; + }; + }; + }; + }; + }; + readonly storageHash: StorageHash; + }>, + 'roots' | 'domain' +> & { + readonly target: 'postgres'; + readonly targetFamily: 'sql'; + readonly roots: { + readonly order: { readonly namespace: 'public' & NamespaceId; readonly model: 'Order' }; + }; + readonly domain: { + readonly namespaces: { + readonly public: { + readonly models: { + readonly Order: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'sql/char@1'; + readonly typeParams: { readonly length: 36 }; + }; + }; + readonly productId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly productName: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly quantity: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + readonly totalCents: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + readonly placedAt: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/timestamptz@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'order'; + readonly namespaceId: 'public'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly productId: { readonly column: 'productId' }; + readonly productName: { readonly column: 'productName' }; + readonly quantity: { readonly column: 'quantity' }; + readonly totalCents: { readonly column: 'totalCents' }; + readonly placedAt: { readonly column: 'placedAt' }; + }; + }; + }; + }; + }; + }; + }; + readonly capabilities: { + readonly postgres: { + readonly distinctOn: true; + readonly jsonAgg: true; + readonly lateral: true; + readonly limit: true; + readonly orderBy: true; + readonly returning: true; + }; + readonly sql: { + readonly defaultInInsert: true; + readonly enums: true; + readonly lateral: true; + readonly returning: true; + }; + }; + readonly extensionPacks: {}; + readonly execution: { + readonly executionHash: ExecutionHash; + readonly mutations: { + readonly defaults: readonly [ + { + readonly ref: { + readonly namespace: 'public'; + readonly table: 'order'; + readonly column: 'id'; + }; + readonly onCreate: { readonly kind: 'generator'; readonly id: 'uuidv4' }; + }, + ]; + }; + }; + readonly meta: {}; + + readonly profileHash: ProfileHash; +}; + +export type Contract = ContractWithTypeMaps; + +export type Namespaces = Contract['storage']['namespaces']; diff --git a/examples/store/modules/orders/contract.json b/examples/store/modules/orders/contract.json new file mode 100644 index 00000000..992cdbfe --- /dev/null +++ b/examples/store/modules/orders/contract.json @@ -0,0 +1,195 @@ +{ + "schemaVersion": "1", + "targetFamily": "sql", + "target": "postgres", + "profileHash": "sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb", + "roots": { + "order": { + "model": "Order", + "namespace": "public" + } + }, + "domain": { + "namespaces": { + "public": { + "models": { + "Order": { + "fields": { + "id": { + "nullable": false, + "type": { + "codecId": "sql/char@1", + "kind": "scalar", + "typeParams": { + "length": 36 + } + } + }, + "placedAt": { + "nullable": false, + "type": { + "codecId": "pg/timestamptz@1", + "kind": "scalar" + } + }, + "productId": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "productName": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "quantity": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + }, + "totalCents": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "fields": { + "id": { + "column": "id" + }, + "placedAt": { + "column": "placedAt" + }, + "productId": { + "column": "productId" + }, + "productName": { + "column": "productName" + }, + "quantity": { + "column": "quantity" + }, + "totalCents": { + "column": "totalCents" + } + }, + "namespaceId": "public", + "table": "order" + } + } + } + } + } + }, + "storage": { + "namespaces": { + "public": { + "entries": { + "table": { + "order": { + "columns": { + "id": { + "codecId": "sql/char@1", + "nativeType": "character", + "nullable": false, + "typeParams": { + "length": 36 + } + }, + "placedAt": { + "codecId": "pg/timestamptz@1", + "default": { + "expression": "now()", + "kind": "function" + }, + "nativeType": "timestamptz", + "nullable": false + }, + "productId": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "productName": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "quantity": { + "codecId": "pg/int4@1", + "nativeType": "int4", + "nullable": false + }, + "totalCents": { + "codecId": "pg/int4@1", + "nativeType": "int4", + "nullable": false + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": ["id"] + }, + "uniques": [] + } + } + }, + "id": "public", + "kind": "postgres-schema" + } + }, + "storageHash": "sha256:c02a1b8e5e4db4eb71986673487f007e103ac093f5af4d5f4c6bb5b4ef84b5ce" + }, + "execution": { + "executionHash": "sha256:8e2d88824468b3baf7490ecbfc7147357b480cb98c89fab4b471b1300cec0f00", + "mutations": { + "defaults": [ + { + "onCreate": { + "id": "uuidv4", + "kind": "generator" + }, + "ref": { + "column": "id", + "namespace": "public", + "table": "order" + } + } + ] + } + }, + "capabilities": { + "postgres": { + "distinctOn": true, + "jsonAgg": true, + "lateral": true, + "limit": true, + "orderBy": true, + "returning": true + }, + "sql": { + "defaultInInsert": true, + "enums": true, + "lateral": true, + "returning": true + } + }, + "extensionPacks": {}, + "meta": {}, + "_generated": { + "warning": "⚠️ GENERATED FILE - DO NOT EDIT", + "message": "This file is automatically generated by \"prisma-next contract emit\".", + "regenerate": "To regenerate, run: prisma-next contract emit" + } +} diff --git a/examples/store/modules/orders/contract.prisma b/examples/store/modules/orders/contract.prisma new file mode 100644 index 00000000..188f2fe5 --- /dev/null +++ b/examples/store/modules/orders/contract.prisma @@ -0,0 +1,13 @@ +// use prisma-next +// +// orders' data contract: one Order per purchase, snapshotting the product's +// name and price at placement time. + +model Order { + id String @id @default(uuid(4)) + productId String + productName String + quantity Int + totalCents Int + placedAt DateTime @default(now()) +} diff --git a/examples/store/modules/orders/migrations/app/20260713T1212_init/end-contract.d.ts b/examples/store/modules/orders/migrations/app/20260713T1212_init/end-contract.d.ts new file mode 100644 index 00000000..5d77d6df --- /dev/null +++ b/examples/store/modules/orders/migrations/app/20260713T1212_init/end-contract.d.ts @@ -0,0 +1,230 @@ +// ⚠️ GENERATED FILE - DO NOT EDIT +// This file is automatically generated by 'prisma-next contract emit'. +// To regenerate, run: prisma-next contract emit +import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; +import type { + Bit, + Char, + CodecTypes as PgTypes, + Interval, + JsonValue, + Numeric, + Time, + Timestamp, + Timestamptz, + Timetz, + VarBit, + Varchar, +} from '@prisma-next/target-postgres/codec-types'; + +import type { + ContractWithTypeMaps, + TypeMaps as TypeMapsType, +} from '@prisma-next/sql-contract/types'; +import type { + Contract as ContractType, + ExecutionHashBase, + NamespaceId, + ProfileHashBase, + StorageHashBase, +} from '@prisma-next/contract/types'; + +export type StorageHash = + StorageHashBase<'sha256:c02a1b8e5e4db4eb71986673487f007e103ac093f5af4d5f4c6bb5b4ef84b5ce'>; +export type ExecutionHash = + ExecutionHashBase<'sha256:8e2d88824468b3baf7490ecbfc7147357b480cb98c89fab4b471b1300cec0f00'>; +export type ProfileHash = + ProfileHashBase<'sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb'>; + +export type CodecTypes = PgTypes; +export type LaneCodecTypes = CodecTypes; +export type QueryOperationTypes = PgAdapterQueryOps; +type DefaultLiteralValue = CodecId extends keyof CodecTypes + ? CodecTypes[CodecId]['output'] + : _Encoded; + +export type FieldOutputTypes = { + readonly public: { + readonly Order: { + readonly id: Char<36>; + readonly productId: CodecTypes['pg/text@1']['output']; + readonly productName: CodecTypes['pg/text@1']['output']; + readonly quantity: CodecTypes['pg/int4@1']['output']; + readonly totalCents: CodecTypes['pg/int4@1']['output']; + readonly placedAt: CodecTypes['pg/timestamptz@1']['output']; + }; + }; +}; +export type FieldInputTypes = { + readonly public: { + readonly Order: { + readonly id: CodecTypes['sql/char@1']['input']; + readonly productId: CodecTypes['pg/text@1']['input']; + readonly productName: CodecTypes['pg/text@1']['input']; + readonly quantity: CodecTypes['pg/int4@1']['input']; + readonly totalCents: CodecTypes['pg/int4@1']['input']; + readonly placedAt: CodecTypes['pg/timestamptz@1']['input']; + }; + }; +}; +export type TypeMaps = TypeMapsType< + CodecTypes, + QueryOperationTypes, + FieldOutputTypes, + FieldInputTypes +>; + +type ContractBase = Omit< + ContractType<{ + readonly namespaces: { + readonly public: { + readonly id: 'public'; + readonly kind: 'postgres-schema'; + readonly entries: { + readonly table: { + readonly order: { + columns: { + readonly id: { + readonly nativeType: 'character'; + readonly codecId: 'sql/char@1'; + readonly nullable: false; + readonly typeParams: { readonly length: 36 }; + }; + readonly productId: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly productName: { + readonly nativeType: 'text'; + readonly codecId: 'pg/text@1'; + readonly nullable: false; + }; + readonly quantity: { + readonly nativeType: 'int4'; + readonly codecId: 'pg/int4@1'; + readonly nullable: false; + }; + readonly totalCents: { + readonly nativeType: 'int4'; + readonly codecId: 'pg/int4@1'; + readonly nullable: false; + }; + readonly placedAt: { + readonly nativeType: 'timestamptz'; + readonly codecId: 'pg/timestamptz@1'; + readonly nullable: false; + readonly default: { readonly kind: 'function'; readonly expression: 'now()' }; + }; + }; + primaryKey: { readonly columns: readonly ['id'] }; + uniques: readonly []; + indexes: readonly []; + foreignKeys: readonly []; + }; + }; + }; + }; + }; + readonly storageHash: StorageHash; + }>, + 'roots' | 'domain' +> & { + readonly target: 'postgres'; + readonly targetFamily: 'sql'; + readonly roots: { + readonly order: { readonly namespace: 'public' & NamespaceId; readonly model: 'Order' }; + }; + readonly domain: { + readonly namespaces: { + readonly public: { + readonly models: { + readonly Order: { + readonly fields: { + readonly id: { + readonly nullable: false; + readonly type: { + readonly kind: 'scalar'; + readonly codecId: 'sql/char@1'; + readonly typeParams: { readonly length: 36 }; + }; + }; + readonly productId: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly productName: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/text@1' }; + }; + readonly quantity: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + readonly totalCents: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/int4@1' }; + }; + readonly placedAt: { + readonly nullable: false; + readonly type: { readonly kind: 'scalar'; readonly codecId: 'pg/timestamptz@1' }; + }; + }; + readonly relations: Record; + readonly storage: { + readonly table: 'order'; + readonly namespaceId: 'public'; + readonly fields: { + readonly id: { readonly column: 'id' }; + readonly productId: { readonly column: 'productId' }; + readonly productName: { readonly column: 'productName' }; + readonly quantity: { readonly column: 'quantity' }; + readonly totalCents: { readonly column: 'totalCents' }; + readonly placedAt: { readonly column: 'placedAt' }; + }; + }; + }; + }; + }; + }; + }; + readonly capabilities: { + readonly postgres: { + readonly distinctOn: true; + readonly jsonAgg: true; + readonly lateral: true; + readonly limit: true; + readonly orderBy: true; + readonly returning: true; + }; + readonly sql: { + readonly defaultInInsert: true; + readonly enums: true; + readonly lateral: true; + readonly returning: true; + }; + }; + readonly extensionPacks: {}; + readonly execution: { + readonly executionHash: ExecutionHash; + readonly mutations: { + readonly defaults: readonly [ + { + readonly ref: { + readonly namespace: 'public'; + readonly table: 'order'; + readonly column: 'id'; + }; + readonly onCreate: { readonly kind: 'generator'; readonly id: 'uuidv4' }; + }, + ]; + }; + }; + readonly meta: {}; + + readonly profileHash: ProfileHash; +}; + +export type Contract = ContractWithTypeMaps; + +export type Namespaces = Contract['storage']['namespaces']; diff --git a/examples/store/modules/orders/migrations/app/20260713T1212_init/end-contract.json b/examples/store/modules/orders/migrations/app/20260713T1212_init/end-contract.json new file mode 100644 index 00000000..43e92eb6 --- /dev/null +++ b/examples/store/modules/orders/migrations/app/20260713T1212_init/end-contract.json @@ -0,0 +1,197 @@ +{ + "schemaVersion": "1", + "targetFamily": "sql", + "target": "postgres", + "profileHash": "sha256:9c8aa3114e84ed3b7ea2bd57526d9c2e1bf7c5292be694e9d3801f566fda7ccb", + "roots": { + "order": { + "model": "Order", + "namespace": "public" + } + }, + "domain": { + "namespaces": { + "public": { + "models": { + "Order": { + "fields": { + "id": { + "nullable": false, + "type": { + "codecId": "sql/char@1", + "kind": "scalar", + "typeParams": { + "length": 36 + } + } + }, + "placedAt": { + "nullable": false, + "type": { + "codecId": "pg/timestamptz@1", + "kind": "scalar" + } + }, + "productId": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "productName": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "quantity": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + }, + "totalCents": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "fields": { + "id": { + "column": "id" + }, + "placedAt": { + "column": "placedAt" + }, + "productId": { + "column": "productId" + }, + "productName": { + "column": "productName" + }, + "quantity": { + "column": "quantity" + }, + "totalCents": { + "column": "totalCents" + } + }, + "namespaceId": "public", + "table": "order" + } + } + } + } + } + }, + "storage": { + "namespaces": { + "public": { + "entries": { + "table": { + "order": { + "columns": { + "id": { + "codecId": "sql/char@1", + "nativeType": "character", + "nullable": false, + "typeParams": { + "length": 36 + } + }, + "placedAt": { + "codecId": "pg/timestamptz@1", + "default": { + "expression": "now()", + "kind": "function" + }, + "nativeType": "timestamptz", + "nullable": false + }, + "productId": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "productName": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "quantity": { + "codecId": "pg/int4@1", + "nativeType": "int4", + "nullable": false + }, + "totalCents": { + "codecId": "pg/int4@1", + "nativeType": "int4", + "nullable": false + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": [ + "id" + ] + }, + "uniques": [] + } + } + }, + "id": "public", + "kind": "postgres-schema" + } + }, + "storageHash": "sha256:c02a1b8e5e4db4eb71986673487f007e103ac093f5af4d5f4c6bb5b4ef84b5ce" + }, + "execution": { + "executionHash": "sha256:8e2d88824468b3baf7490ecbfc7147357b480cb98c89fab4b471b1300cec0f00", + "mutations": { + "defaults": [ + { + "onCreate": { + "id": "uuidv4", + "kind": "generator" + }, + "ref": { + "column": "id", + "namespace": "public", + "table": "order" + } + } + ] + } + }, + "capabilities": { + "postgres": { + "distinctOn": true, + "jsonAgg": true, + "lateral": true, + "limit": true, + "orderBy": true, + "returning": true + }, + "sql": { + "defaultInInsert": true, + "enums": true, + "lateral": true, + "returning": true + } + }, + "extensionPacks": {}, + "meta": {}, + "_generated": { + "warning": "⚠️ GENERATED FILE - DO NOT EDIT", + "message": "This file is automatically generated by \"prisma-next contract emit\".", + "regenerate": "To regenerate, run: prisma-next contract emit" + } +} \ No newline at end of file diff --git a/examples/store/modules/orders/migrations/app/20260713T1212_init/migration.json b/examples/store/modules/orders/migrations/app/20260713T1212_init/migration.json new file mode 100644 index 00000000..e8bb1cbd --- /dev/null +++ b/examples/store/modules/orders/migrations/app/20260713T1212_init/migration.json @@ -0,0 +1,7 @@ +{ + "from": null, + "to": "sha256:c02a1b8e5e4db4eb71986673487f007e103ac093f5af4d5f4c6bb5b4ef84b5ce", + "providedInvariants": [], + "createdAt": "2026-07-13T12:12:52.644Z", + "migrationHash": "sha256:d8ce0a69e6df0a866f3b0a0569a0661cda7a316a7a5c41297922d3074f93ffbd" +} \ No newline at end of file diff --git a/examples/store/modules/orders/migrations/app/20260713T1212_init/migration.ts b/examples/store/modules/orders/migrations/app/20260713T1212_init/migration.ts new file mode 100755 index 00000000..16696253 --- /dev/null +++ b/examples/store/modules/orders/migrations/app/20260713T1212_init/migration.ts @@ -0,0 +1,38 @@ +#!/usr/bin/env -S bun +import { Migration, MigrationCLI, col, fn, primaryKey } from '@prisma-next/postgres/migration'; + +export default class M extends Migration { + override describe() { + return { + from: null, + to: 'sha256:c02a1b8e5e4db4eb71986673487f007e103ac093f5af4d5f4c6bb5b4ef84b5ce', + }; + } + + override get operations() { + return [ + this.createTable({ + schema: 'public', + table: 'order', + columns: [ + col('id', 'character(36)', { + notNull: true, + codecRef: { codecId: 'sql/char@1', typeParams: { length: 36 } }, + }), + col('placedAt', 'timestamptz', { + notNull: true, + default: fn('now()'), + codecRef: { codecId: 'pg/timestamptz@1' }, + }), + col('productId', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }), + col('productName', 'text', { notNull: true, codecRef: { codecId: 'pg/text@1' } }), + col('quantity', 'int4', { notNull: true, codecRef: { codecId: 'pg/int4@1' } }), + col('totalCents', 'int4', { notNull: true, codecRef: { codecId: 'pg/int4@1' } }), + ], + constraints: [primaryKey(['id'])], + }), + ]; + } +} + +MigrationCLI.run(import.meta.url, M); diff --git a/examples/store/modules/orders/migrations/app/20260713T1212_init/ops.json b/examples/store/modules/orders/migrations/app/20260713T1212_init/ops.json new file mode 100644 index 00000000..6ab78ede --- /dev/null +++ b/examples/store/modules/orders/migrations/app/20260713T1212_init/ops.json @@ -0,0 +1,41 @@ +[ + { + "id": "table.order", + "label": "Create table \"order\"", + "summary": "Creates table \"order\"", + "operationClass": "additive", + "target": { + "id": "postgres", + "details": { + "schema": "public", + "objectType": "table", + "name": "order" + } + }, + "precheck": [ + { + "description": "ensure table \"order\" does not exist", + "sql": "SELECT (to_regclass($1)) IS NULL AS \"result\"", + "params": [ + "\"public\".\"order\"" + ] + } + ], + "execute": [ + { + "description": "create table \"order\"", + "sql": "CREATE TABLE \"public\".\"order\" (\n \"id\" character(36) NOT NULL,\n \"placedAt\" timestamptz DEFAULT (now()) NOT NULL,\n \"productId\" text NOT NULL,\n \"productName\" text NOT NULL,\n \"quantity\" int4 NOT NULL,\n \"totalCents\" int4 NOT NULL,\n PRIMARY KEY (\"id\")\n)", + "params": [] + } + ], + "postcheck": [ + { + "description": "verify table \"order\" exists", + "sql": "SELECT (to_regclass($1)) IS NOT NULL AS \"result\"", + "params": [ + "\"public\".\"order\"" + ] + } + ] + } +] \ No newline at end of file diff --git a/examples/store/modules/orders/package.json b/examples/store/modules/orders/package.json new file mode 100644 index 00000000..9ffecd0b --- /dev/null +++ b/examples/store/modules/orders/package.json @@ -0,0 +1,33 @@ +{ + "name": "@store/orders", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/module.ts", + "./contract": "./src/contract.ts", + "./fake": "./testing/fake.ts" + }, + "scripts": { + "dev": "bun run scripts/dev.ts", + "build": "tsdown", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@prisma-next/postgres": "0.14.0", + "@store/catalog": "workspace:0.1.0", + "arktype": "^2.2.3", + "pg": "8.21.0" + }, + "peerDependencies": { + "@prisma/compose": "workspace:0.1.0", + "@prisma/compose-prisma-cloud": "workspace:0.1.0" + }, + "devDependencies": { + "@prisma/compose": "workspace:0.1.0", + "@prisma/compose-prisma-cloud": "workspace:0.1.0", + "@types/bun": "^1.3.13", + "tsdown": "^0.22.3", + "typescript": "^6.0.3" + } +} diff --git a/examples/store/modules/orders/prisma-next.config.ts b/examples/store/modules/orders/prisma-next.config.ts new file mode 100644 index 00000000..13737a48 --- /dev/null +++ b/examples/store/modules/orders/prisma-next.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from '@prisma-next/postgres/config'; + +// Anchors the contract source and migrations/ on disk. The deploy lowering +// loads it (by path, from the pnPostgres resource's `config`) to find the +// migrations — the app build never imports it. `db.connection` is dead +// weight: the framework injects the URL at hydrate (no-globals). +export default defineConfig({ + contract: './contract.prisma', + db: { connection: 'postgres://localhost:5432/placeholder' }, +}); diff --git a/examples/store/modules/orders/scripts/dev.ts b/examples/store/modules/orders/scripts/dev.ts new file mode 100644 index 00000000..3bbbb689 --- /dev/null +++ b/examples/store/modules/orders/scripts/dev.ts @@ -0,0 +1,3 @@ +// Local dev: run the server directly. It calls service.load(), which reads +// DB_URL, CATALOG_URL and PORT from your environment — set them before running. +import '../src/server.ts'; diff --git a/examples/store/modules/orders/src/contract.ts b/examples/store/modules/orders/src/contract.ts new file mode 100644 index 00000000..fd749dc9 --- /dev/null +++ b/examples/store/modules/orders/src/contract.ts @@ -0,0 +1,28 @@ +/** + * orders' public RPC contract. `placeOrder` snapshots the product's name and + * price at placement time — later catalog changes don't rewrite history. + */ +import { contract, rpc } from '@prisma/compose/rpc'; +import { type } from 'arktype'; + +export const order = type({ + id: 'string', + productId: 'string', + productName: 'string', + quantity: 'number', + totalCents: 'number', + placedAt: 'string', +}); + +export type Order = typeof order.infer; + +export const ordersContract = contract({ + placeOrder: rpc({ + input: type({ productId: 'string', quantity: 'number' }), + output: type({ order: order.or('null') }), + }), + listOrders: rpc({ + input: type({}), + output: type({ orders: order.array() }), + }), +}); diff --git a/examples/store/modules/orders/src/data.ts b/examples/store/modules/orders/src/data.ts new file mode 100644 index 00000000..32cfd82c --- /dev/null +++ b/examples/store/modules/orders/src/data.ts @@ -0,0 +1,11 @@ +/** + * orders' Prisma Next data contract wrapped into the framework's + * `prisma-next` kind — referenced by both the resource end (module.ts) and + * the dependency end (service.ts). Emitted from contract.prisma by + * `prisma-next contract emit`. + */ +import { pnContract } from '@prisma/compose-prisma-cloud/prisma-next'; +import type { Contract } from '../contract.d.ts'; +import contractJson from '../contract.json' with { type: 'json' }; + +export const ordersData = pnContract(contractJson); diff --git a/examples/store/modules/orders/src/module.ts b/examples/store/modules/orders/src/module.ts new file mode 100644 index 00000000..14cb76a8 --- /dev/null +++ b/examples/store/modules/orders/src/module.ts @@ -0,0 +1,29 @@ +/** + * The orders Module: owns its own Prisma Next-typed Postgres (ADR-0022), but + * NOT the catalog — that comes in through the module's boundary + * (`deps.catalog`), wired by whoever provisions this module. The consumer + * supplies any producer of `catalogContract`; orders never knows which. + */ +import { fileURLToPath } from 'node:url'; +import { module } from '@prisma/compose'; +import { rpc } from '@prisma/compose/rpc'; +import { pnPostgres } from '@prisma/compose-prisma-cloud/prisma-next'; +import { catalogContract } from '@store/catalog/contract'; +import { ordersContract } from './contract.ts'; +import { ordersData } from './data.ts'; +import ordersService from './service.ts'; + +const config = fileURLToPath(new URL('../prisma-next.config.ts', import.meta.url)); + +export default module( + 'orders', + { deps: { catalog: rpc(catalogContract) }, expose: { rpc: ordersContract } }, + ({ inputs, provision }) => { + const db = provision(pnPostgres({ name: 'database', contract: ordersData, config })); + const service = provision(ordersService, { + id: 'service', + deps: { db, catalog: inputs.catalog }, + }); + return { rpc: service.rpc }; + }, +); diff --git a/examples/store/modules/orders/src/server.ts b/examples/store/modules/orders/src/server.ts new file mode 100644 index 00000000..d90942e4 --- /dev/null +++ b/examples/store/modules/orders/src/server.ts @@ -0,0 +1,43 @@ +import { serve } from '@prisma/compose/rpc'; +import service from './service.ts'; + +// load() hydrates both deps: `db` is the Prisma Next typed client (ADR-0022), +// `catalog` a typed client of catalogContract — both plain async calls. +const { db, catalog } = service.load(); +const { port } = service.config(); + +// A Prisma Postgres direct connection is dropped when it idles / the service +// scales to zero; the lazy pool reconnects on the next query. Surface those +// as logs, not an uncaught crash into a 502 restart loop. +process.on('uncaughtException', (err) => console.error('uncaughtException', err)); +process.on('unhandledRejection', (err) => console.error('unhandledRejection', err)); + +const handler = serve(service, { + rpc: { + placeOrder: async ({ productId, quantity }) => { + const { product } = await catalog.getProduct({ id: productId }); + if (product === null || quantity < 1) return { order: null }; + + const row = await db.orm.public.Order.create({ + productId: product.id, + productName: product.name, + quantity, + totalCents: product.priceCents * quantity, + }); + return { order: { ...row, placedAt: new Date(row.placedAt).toISOString() } }; + }, + listOrders: async () => { + const rows = await db.orm.public.Order.orderBy((o) => o.placedAt.desc()) + .take(20) + .all(); + return { + orders: rows.map((o) => ({ ...o, placedAt: new Date(o.placedAt).toISOString() })), + }; + }, + }, +}); +export default handler; + +// Bind all interfaces — Compute routes external HTTP to the VM, so a +// loopback-only listener would be unreachable. +Bun.serve({ port, hostname: '0.0.0.0', fetch: handler }); diff --git a/examples/store/modules/orders/src/service.ts b/examples/store/modules/orders/src/service.ts new file mode 100644 index 00000000..cc8093d2 --- /dev/null +++ b/examples/store/modules/orders/src/service.ts @@ -0,0 +1,20 @@ +import node from '@prisma/compose/node'; +import { rpc } from '@prisma/compose/rpc'; +import { compute } from '@prisma/compose-prisma-cloud'; +import { pnPostgres } from '@prisma/compose-prisma-cloud/prisma-next'; +import { catalogContract } from '@store/catalog/contract'; +import { ordersContract } from './contract.ts'; +import { ordersData } from './data.ts'; + +// Two dependencies, two kinds: `db` hydrates to the Prisma Next typed client +// (ADR-0022); `catalog` hydrates to a typed client of another module's rpc +// contract. +export default compute({ + name: 'orders', + deps: { + db: pnPostgres(ordersData), + catalog: rpc(catalogContract), + }, + build: node({ module: import.meta.url, entry: '../dist/server.mjs' }), + expose: { rpc: ordersContract }, +}); diff --git a/examples/store/modules/orders/testing/fake.ts b/examples/store/modules/orders/testing/fake.ts new file mode 100644 index 00000000..eb111a72 --- /dev/null +++ b/examples/store/modules/orders/testing/fake.ts @@ -0,0 +1,44 @@ +/** + * An in-memory orders service for TESTING a module that depends on it — no + * Postgres, no catalog, no deploy. It serves the real `ordersContract`, so its + * handler map is type-checked against the same contract the real orders + * exposes. Test-only, deliberately outside `src/`. + */ +import node from '@prisma/compose/node'; +import { serve } from '@prisma/compose/rpc'; +import { compute } from '@prisma/compose-prisma-cloud'; +import { type Order, ordersContract } from '../src/contract.ts'; + +export const FAKE_ORDERS: Order[] = [ + { + id: 'order-1', + productId: 'espresso', + productName: 'Espresso', + quantity: 2, + totalCents: 700, + placedAt: '2026-07-13T08:00:00.000Z', + }, +]; + +const fakeOrders = compute({ + name: 'orders-fake', + deps: {}, + build: node({ module: import.meta.url, entry: 'fake.ts' }), + expose: { rpc: ordersContract }, +}); + +export default serve(fakeOrders, { + rpc: { + placeOrder: async ({ productId, quantity }) => ({ + order: { + id: `order-${FAKE_ORDERS.length + 1}`, + productId, + productName: productId, + quantity, + totalCents: 100 * quantity, + placedAt: '2026-07-13T09:00:00.000Z', + }, + }), + listOrders: async () => ({ orders: FAKE_ORDERS }), + }, +}); diff --git a/examples/store/modules/orders/tsconfig.json b/examples/store/modules/orders/tsconfig.json new file mode 100644 index 00000000..66ddc2c7 --- /dev/null +++ b/examples/store/modules/orders/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "Preserve", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": false, + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "types": ["bun"] + }, + "include": ["src", "contract.d.ts"] +} diff --git a/examples/store/modules/orders/tsdown.config.ts b/examples/store/modules/orders/tsdown.config.ts new file mode 100644 index 00000000..aa55554f --- /dev/null +++ b/examples/store/modules/orders/tsdown.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsdown'; + +// Build only the runnable (src/server.ts). @prisma/*, @store/* and arktype are +// inlined — node_modules isn't shipped; `bun` is a Compute runtime built-in. +export default defineConfig({ + entry: { server: 'src/server.ts' }, + outDir: 'dist', + format: 'esm', + platform: 'node', + external: ['bun'], + noExternal: [/^@prisma\//, /^@prisma-next\//, /^@store\//, /^arktype/, /^pg$/, /^pg-/, /^pathe$/], + dts: false, + sourcemap: false, + clean: true, +}); diff --git a/examples/store/modules/promotions/package.json b/examples/store/modules/promotions/package.json new file mode 100644 index 00000000..5a3a267c --- /dev/null +++ b/examples/store/modules/promotions/package.json @@ -0,0 +1,28 @@ +{ + "name": "@store/promotions", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/service.ts" + }, + "scripts": { + "build": "tsdown", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@store/catalog": "workspace:0.1.0", + "arktype": "^2.2.3" + }, + "peerDependencies": { + "@prisma/compose": "workspace:0.1.0", + "@prisma/compose-prisma-cloud": "workspace:0.1.0" + }, + "devDependencies": { + "@prisma/compose": "workspace:0.1.0", + "@prisma/compose-prisma-cloud": "workspace:0.1.0", + "@types/bun": "^1.3.13", + "tsdown": "^0.22.3", + "typescript": "^6.0.3" + } +} diff --git a/examples/store/modules/promotions/src/server.ts b/examples/store/modules/promotions/src/server.ts new file mode 100644 index 00000000..bea9497a --- /dev/null +++ b/examples/store/modules/promotions/src/server.ts @@ -0,0 +1,19 @@ +import { serveSchedule } from '@prisma/compose-prisma-cloud/cron'; +import service, { schedule } from './service.ts'; + +// serveSchedule is exhaustive over the schedule's job ids at compile time — +// omitting `rotateSpecial` here would be a type error. The handler body is +// where a job id maps to whatever calls implement it. +const handler = serveSchedule(service, schedule, { + rotateSpecial: async (deps) => { + const { product } = await deps.catalog.rotateSpecial({}); + console.log(`special rotated to ${product?.name ?? '(no products)'}`); + }, +}); +export default handler; + +const { port } = service.config(); + +// Bind all interfaces — Compute routes external HTTP to the VM, so a +// loopback-only listener would be unreachable. +Bun.serve({ port, hostname: '0.0.0.0', fetch: handler }); diff --git a/examples/store/modules/promotions/src/service.ts b/examples/store/modules/promotions/src/service.ts new file mode 100644 index 00000000..1c3dae54 --- /dev/null +++ b/examples/store/modules/promotions/src/service.ts @@ -0,0 +1,20 @@ +/** + * The promotions runner: the work target the shared cron module fires at + * (ADR-0020). The schedule lives here — the one source of truth for the job + * ids serveSchedule forces handlers for and the intervals the scheduler + * fires on. Rotates the catalog's special of the day. + */ +import node from '@prisma/compose/node'; +import { rpc } from '@prisma/compose/rpc'; +import { compute } from '@prisma/compose-prisma-cloud'; +import { defineSchedule, triggerContract } from '@prisma/compose-prisma-cloud/cron'; +import { catalogContract } from '@store/catalog/contract'; + +export const schedule = defineSchedule({ rotateSpecial: '30s' }); + +export default compute({ + name: 'promotions', + deps: { catalog: rpc(catalogContract) }, + build: node({ module: import.meta.url, entry: '../dist/server.mjs' }), + expose: { trigger: triggerContract }, +}); diff --git a/examples/store/modules/promotions/tsconfig.json b/examples/store/modules/promotions/tsconfig.json new file mode 100644 index 00000000..5b986d35 --- /dev/null +++ b/examples/store/modules/promotions/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "Preserve", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": false, + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "types": ["bun"] + }, + "include": ["src"] +} diff --git a/examples/store/modules/promotions/tsdown.config.ts b/examples/store/modules/promotions/tsdown.config.ts new file mode 100644 index 00000000..c0325ced --- /dev/null +++ b/examples/store/modules/promotions/tsdown.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsdown'; + +// Build only the runnable (src/server.ts). @prisma/*, @store/* and arktype are +// inlined — node_modules isn't shipped; `bun` is a Compute runtime built-in. +export default defineConfig({ + entry: { server: 'src/server.ts' }, + outDir: 'dist', + format: 'esm', + platform: 'node', + external: ['bun'], + noExternal: [/^@prisma\//, /^@store\//, /^arktype/], + dts: false, + sourcemap: false, + clean: true, +}); diff --git a/examples/store/modules/storefront/.gitignore b/examples/store/modules/storefront/.gitignore new file mode 100644 index 00000000..81f74ff7 --- /dev/null +++ b/examples/store/modules/storefront/.gitignore @@ -0,0 +1,2 @@ +.next/ +next-env.d.ts diff --git a/examples/store/modules/storefront/app/globals.css b/examples/store/modules/storefront/app/globals.css new file mode 100644 index 00000000..01a8ac3a --- /dev/null +++ b/examples/store/modules/storefront/app/globals.css @@ -0,0 +1,72 @@ +:root { + color-scheme: light dark; + font-family: ui-sans-serif, system-ui, sans-serif; +} + +body { + margin: 0 auto; + max-width: 40rem; + padding: 2rem 1rem; + line-height: 1.5; +} + +h1 { + margin-bottom: 0; +} + +.tagline { + margin-top: 0.25rem; + opacity: 0.7; +} + +.product { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 0; + border-bottom: 1px solid color-mix(in srgb, currentColor 15%, transparent); +} + +.product-info { + flex: 1; +} + +.product-info p { + margin: 0; + opacity: 0.7; + font-size: 0.9rem; +} + +.product-info strong { + font-size: 1.05rem; +} + +.price { + font-variant-numeric: tabular-nums; +} + +.special { + margin-left: 0.5rem; + font-size: 0.85rem; + color: #b8860b; +} + +button { + font: inherit; + padding: 0.4rem 1rem; + border: 1px solid currentColor; + border-radius: 0.5rem; + background: transparent; + cursor: pointer; +} + +.order { + display: flex; + justify-content: space-between; + padding: 0.4rem 0; + font-size: 0.95rem; +} + +.order time { + opacity: 0.6; +} diff --git a/examples/store/modules/storefront/app/layout.tsx b/examples/store/modules/storefront/app/layout.tsx new file mode 100644 index 00000000..17d97082 --- /dev/null +++ b/examples/store/modules/storefront/app/layout.tsx @@ -0,0 +1,11 @@ +import './globals.css'; + +export const metadata = { title: 'Compose Coffee' }; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/examples/store/modules/storefront/app/page.test.tsx b/examples/store/modules/storefront/app/page.test.tsx new file mode 100644 index 00000000..12b843cc --- /dev/null +++ b/examples/store/modules/storefront/app/page.test.tsx @@ -0,0 +1,62 @@ +/** + * Unit proof (testing.md § Unit): mocks storefront's own service module so + * `load()` returns typed fakes for catalog and orders via `mockService`, then + * renders the page directly — no server, no environment, no cloud. + */ +import { mockService } from '@prisma/compose/testing'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; +import type Service from '../src/service.ts'; + +vi.mock('../src/service.ts', async () => { + const actual = await vi.importActual<{ default: typeof Service }>('../src/service.ts'); + return { + default: mockService(actual.default, { + catalog: { + listProducts: async () => ({ + products: [ + { id: 'espresso', name: 'Espresso', description: 'A double shot.', priceCents: 350 }, + ], + }), + getProduct: async () => ({ product: null }), + getSpecial: async () => ({ + product: { + id: 'espresso', + name: 'Espresso', + description: 'A double shot.', + priceCents: 350, + }, + }), + rotateSpecial: async () => ({ product: null }), + }, + orders: { + placeOrder: async () => ({ order: null }), + listOrders: async () => ({ + orders: [ + { + id: 'order-1', + productId: 'espresso', + productName: 'Espresso', + quantity: 2, + totalCents: 700, + placedAt: '2026-07-13T08:00:00.000Z', + }, + ], + }), + }, + }), + }; +}); + +describe('Home (page.tsx)', () => { + it('renders products from catalog and orders from orders, load() stubbed to fakes', async () => { + const { default: Home } = await import('./page.tsx'); + + const html = renderToStaticMarkup(await Home()); + + expect(html).toContain('Espresso'); + expect(html).toContain('$3.50'); + expect(html).toContain('$7.00'); + expect(html).toContain('today’s special'); + }); +}); diff --git a/examples/store/modules/storefront/app/page.tsx b/examples/store/modules/storefront/app/page.tsx new file mode 100644 index 00000000..8862a6f8 --- /dev/null +++ b/examples/store/modules/storefront/app/page.tsx @@ -0,0 +1,61 @@ +import { revalidatePath } from 'next/cache'; +import service from '../src/service.ts'; + +// service.load() needs the runtime environment, which doesn't exist at build +// time — so render per request instead of prerendering. +export const dynamic = 'force-dynamic'; + +const price = (cents: number) => `$${(cents / 100).toFixed(2)}`; + +async function buy(formData: FormData) { + 'use server'; + const { orders } = service.load(); + await orders.placeOrder({ productId: String(formData.get('productId')), quantity: 1 }); + revalidatePath('/'); +} + +export default async function Home() { + // Two typed clients, injected by the root module's wiring. This page + // doesn't know where catalog and orders run — only their contracts. + const { catalog, orders } = service.load(); + const [{ products }, { orders: recent }, { product: special }] = await Promise.all([ + catalog.listProducts({}), + orders.listOrders({}), + catalog.getSpecial({}), + ]); + + return ( +
+

Compose Coffee

+

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

+ +

Menu

+ {products.map((p) => ( +
+
+ {p.name} {price(p.priceCents)} + {special?.id === p.id && ★ today’s special} +

{p.description}

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

Recent orders

+ {recent.length === 0 &&

No orders yet — buy something!

} + {recent.map((o) => ( +
+ + {o.quantity} × {o.productName} — {price(o.totalCents)} + + +
+ ))} +
+ ); +} diff --git a/examples/store/modules/storefront/next.config.ts b/examples/store/modules/storefront/next.config.ts new file mode 100644 index 00000000..ac764c84 --- /dev/null +++ b/examples/store/modules/storefront/next.config.ts @@ -0,0 +1,24 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { NextConfig } from 'next'; + +// The monorepo root: standalone file tracing must start here, or Next walks up +// to the outer checkout's lockfile and traces the wrong node_modules. +const workspaceRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../../..'); + +// Standalone output is what Prisma Compute deploys — a self-contained server.js +// plus the minimal node_modules, not a `next start` dev server. +const nextConfig: NextConfig = { + output: 'standalone', + outputFileTracingRoot: workspaceRoot, + images: { unoptimized: true }, + // Next traces native binaries (`sharp`, `@next/swc`) built for THIS + // machine's platform into the standalone; on Compute's linux VM bun + // auto-installs their linux variants at boot and fills the tiny disk + // (ENOSPC crash loop). Exclude them from the trace. + outputFileTracingExcludes: { + '*': ['**/node_modules/@next/swc-*/**', '**/node_modules/sharp/**', '**/node_modules/@img/**'], + }, +}; + +export default nextConfig; diff --git a/examples/store/modules/storefront/package.json b/examples/store/modules/storefront/package.json new file mode 100644 index 00000000..5c0d5b22 --- /dev/null +++ b/examples/store/modules/storefront/package.json @@ -0,0 +1,33 @@ +{ + "name": "@store/storefront", + "version": "0.1.0", + "private": true, + "exports": { + ".": "./src/service.ts" + }, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@prisma/compose": "workspace:0.1.0", + "@prisma/compose-prisma-cloud": "workspace:0.1.0", + "@store/catalog": "workspace:0.1.0", + "@store/orders": "workspace:0.1.0", + "arktype": "^2.2.3", + "next": "^16.2.9", + "react": "19.2.7", + "react-dom": "19.2.7" + }, + "devDependencies": { + "@types/bun": "^1.3.13", + "@types/node": "^25.9.3", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.0", + "typescript": "^6.0.3", + "vitest": "^4.1.9" + } +} diff --git a/examples/store/modules/storefront/src/service.ts b/examples/store/modules/storefront/src/service.ts new file mode 100644 index 00000000..50428cc3 --- /dev/null +++ b/examples/store/modules/storefront/src/service.ts @@ -0,0 +1,17 @@ +import nextjs from '@prisma/compose/nextjs'; +import { rpc } from '@prisma/compose/rpc'; +import { compute } from '@prisma/compose-prisma-cloud'; +import { catalogContract } from '@store/catalog/contract'; +import { ordersContract } from '@store/orders/contract'; + +// The storefront's whole declaration: what it is (a Next.js compute service) +// and what it needs (typed clients of the catalog and orders contracts). Who +// provides them is the root module's decision, not this file's. +export default compute({ + name: 'storefront', + deps: { + catalog: rpc(catalogContract), + orders: rpc(ordersContract), + }, + build: nextjs({ module: import.meta.url, appDir: '..', entry: 'server.js' }), +}); diff --git a/examples/store/modules/storefront/tsconfig.json b/examples/store/modules/storefront/tsconfig.json new file mode 100644 index 00000000..57b85d97 --- /dev/null +++ b/examples/store/modules/storefront/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": ["node_modules"] +} diff --git a/examples/store/modules/storefront/vitest.config.ts b/examples/store/modules/storefront/vitest.config.ts new file mode 100644 index 00000000..ee8dda7e --- /dev/null +++ b/examples/store/modules/storefront/vitest.config.ts @@ -0,0 +1,13 @@ +import { configDefaults, defineConfig } from 'vitest/config'; + +// page.tsx relies on Next's automatic JSX runtime (no `import React` in +// scope) and its own tsconfig sets `jsx: "preserve"` for Next's own +// compiler — vite's oxc transform needs an explicit override so it doesn't +// inherit that setting. +export default defineConfig({ + oxc: { jsx: { runtime: 'automatic' } }, + test: { + environment: 'node', + exclude: [...configDefaults.exclude, '**/*.integration.test.ts'], + }, +}); diff --git a/examples/store/package.json b/examples/store/package.json new file mode 100644 index 00000000..f29db75e --- /dev/null +++ b/examples/store/package.json @@ -0,0 +1,29 @@ +{ + "name": "@prisma/example-store", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "echo 'nothing to build for this package itself — the modules build via turbo ^build, from the workspace deps below'", + "dev": "bun run scripts/dev.ts", + "typecheck": "tsc --noEmit", + "deploy": "pnpm turbo run build --filter @prisma/example-store... && ( set -a; . ../../.env; set +a; bun node_modules/.bin/prisma-compose deploy module.ts ${STORE_STACK_NAME:+--name \"$STORE_STACK_NAME\"} )", + "destroy": "( set -a; . ../../.env; set +a; bun node_modules/.bin/prisma-compose destroy module.ts --production ${STORE_STACK_NAME:+--name \"$STORE_STACK_NAME\"} )" + }, + "dependencies": { + "@effect/platform-bun": "4.0.0-beta.92", + "@prisma/compose": "workspace:0.1.0", + "@prisma/compose-prisma-cloud": "workspace:0.1.0", + "@store/catalog": "workspace:0.1.0", + "@store/orders": "workspace:0.1.0", + "@store/promotions": "workspace:0.1.0", + "@store/storefront": "workspace:0.1.0", + "alchemy": "2.0.0-beta.59", + "arktype": "^2.2.3", + "effect": "4.0.0-beta.92" + }, + "devDependencies": { + "@types/bun": "^1.3.13", + "typescript": "^6.0.3" + } +} diff --git a/examples/store/prisma-compose.config.ts b/examples/store/prisma-compose.config.ts new file mode 100644 index 00000000..1b58bb00 --- /dev/null +++ b/examples/store/prisma-compose.config.ts @@ -0,0 +1,13 @@ +/** + * The app's control-plane config (ADR-0017) — read ONLY by `prisma-compose + * deploy`/`destroy`, never imported by app code. + */ +import { defineConfig } from '@prisma/compose/config'; +import { nextjsBuild } from '@prisma/compose/nextjs/control'; +import { nodeBuild } from '@prisma/compose/node/control'; +import { prismaCloud, prismaState } from '@prisma/compose-prisma-cloud/control'; + +export default defineConfig({ + extensions: [prismaCloud(), nodeBuild(), nextjsBuild()], + state: () => prismaState(), +}); diff --git a/examples/store/scripts/dev.ts b/examples/store/scripts/dev.ts new file mode 100644 index 00000000..81ce11a1 --- /dev/null +++ b/examples/store/scripts/dev.ts @@ -0,0 +1,55 @@ +/** + * Local dev, no cloud: serve an in-memory catalog and orders on loopback + * ports, then run the storefront via `next dev` with CATALOG_URL/ORDERS_URL + * pointing at them. The storefront can't tell the difference — it talks to + * any producer of the contracts, and these fakes are type-checked against + * the same contracts the real modules serve. + */ +import node from '@prisma/compose/node'; +import { serve } from '@prisma/compose/rpc'; +import { compute } from '@prisma/compose-prisma-cloud'; +import fakeCatalogHandler, { FAKE_PRODUCTS } from '@store/catalog/fake'; +import { type Order, ordersContract } from '@store/orders/contract'; + +const catalog = Bun.serve({ port: 0, fetch: fakeCatalogHandler }); + +// Stateful, unlike @store/orders/fake: placed orders show up in the UI. +const placed: Order[] = []; +const ordersNode = compute({ + name: 'orders-dev', + deps: {}, + build: node({ module: import.meta.url, entry: 'dev.ts' }), + expose: { rpc: ordersContract }, +}); +const orders = Bun.serve({ + port: 0, + fetch: serve(ordersNode, { + rpc: { + placeOrder: async ({ productId, quantity }) => { + const product = FAKE_PRODUCTS.find((p) => p.id === productId); + if (!product || quantity < 1) return { order: null }; + const order: Order = { + id: crypto.randomUUID(), + productId: product.id, + productName: product.name, + quantity, + totalCents: product.priceCents * quantity, + placedAt: new Date().toISOString(), + }; + placed.unshift(order); + return { order }; + }, + listOrders: async () => ({ orders: placed }), + }, + }), +}); + +console.log(`fake catalog ${catalog.url}`); +console.log(`fake orders ${orders.url}`); + +const storefront = Bun.spawn(['pnpm', 'next', 'dev'], { + cwd: new URL('../modules/storefront', import.meta.url).pathname, + env: { ...process.env, CATALOG_URL: catalog.url.href, ORDERS_URL: orders.url.href }, + stdio: ['inherit', 'inherit', 'inherit'], +}); +await storefront.exited; diff --git a/examples/store/tsconfig.json b/examples/store/tsconfig.json new file mode 100644 index 00000000..e09a8d21 --- /dev/null +++ b/examples/store/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["bun"] + }, + "include": ["module.ts"] +} diff --git a/examples/store/turbo.json b/examples/store/turbo.json new file mode 100644 index 00000000..b743d5d2 --- /dev/null +++ b/examples/store/turbo.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "outputs": [] + } + } +} diff --git a/gotchas.md b/gotchas.md index efa3abbf..24b2bf8a 100644 --- a/gotchas.md +++ b/gotchas.md @@ -27,6 +27,8 @@ The capture workflow is the Ignite `product-record-gotcha` skill. - [Branch create has no idempotency — a duplicate gitName 409s, with no create-or-return](#branch-create-has-no-idempotency--a-duplicate-gitname-409s-with-no-create-or-return) - [A project-scoped compute-service create lands on the default branch — and collides with production](#a-project-scoped-compute-service-create-lands-on-the-default-branch--and-collides-with-production) - [First connection to a freshly-provisioned Postgres is rejected while the upstream is cold — breaks deploy-time migrations](#first-connection-to-a-freshly-provisioned-postgres-is-rejected-while-the-upstream-is-cold--breaks-deploy-time-migrations) +- [Idle direct-connection close kills pooled node-postgres clients — first query after idle 500s with "Connection terminated unexpectedly"](#idle-direct-connection-close-kills-pooled-node-postgres-clients--first-query-after-idle-500s-with-connection-terminated-unexpectedly) +- [Service-to-service HTTP gets ECONNRESET while the target cold-starts from scale-to-zero](#service-to-service-http-gets-econnreset-while-the-target-cold-starts-from-scale-to-zero) --- @@ -329,3 +331,68 @@ await withConnectionRetry(() => client.dbInit(...), { attempts: 12, delayMs: 500 - Workaround source: [`packages/app-cloud/src/prisma-next-migrate.ts`](packages/app-cloud/src/prisma-next-migrate.ts) (`withConnectionRetry`) - Removal guard: the CI canary (`examples/scripts/cold-connect-canary.ts`, "Cold-connect canary" E2E job) passes only while the rejection exists — when the platform fixes FT-5226 it goes red, forcing removal of `withConnectionRetry` and itself - Related: [FT-5219](https://linear.app/prisma-company/issue/FT-5219) (idle-close, runtime), [PRO-212](https://linear.app/prisma-company/issue/PRO-212) (nested endpoint DSNs) + +--- + +## Idle direct-connection close kills pooled node-postgres clients — first query after idle 500s with "Connection terminated unexpectedly" + +**Filed upstream:** [PRO-216](https://linear.app/prisma-company/issue/PRO-216/idle-direct-connection-close-kills-pooled-node-postgres-clients-first) — _"Idle direct-connection close kills pooled node-postgres clients — first query after idle fails with 'Connection terminated unexpectedly'"_ +**Product:** Prisma Postgres idle-close × Prisma Compute scale-to-zero (the FT-5219 family, pooled-client variant) +**Version:** `pg` 8.21.0 `Pool` (via `@prisma-next/postgres` 0.14.0); PPg direct connection; Prisma Compute (scale-to-zero) +**First hit:** `examples/store` — the orders service after an idle spell; presented as the storefront rendering Next's error page +**Cost:** ~1 hour — two separate "the demo URL is down" reports before the service logs were pulled + +**Symptom.** A service using a node-postgres `Pool` works right after deploy, then after sitting idle its first DB-backed request fails once with `Connection terminated unexpectedly` (surfaced here as an RPC 500; the consumer's SSR render then 500s). The next request works. No crash loop — just a reliable one-request failure after every idle spell. + +**Cause.** Prisma Postgres closes idle direct connections well under 30 s. A pool with a longer idle timeout (`idleTimeoutMillis: 30_000` here) keeps the dead socket checked in and hands it to the next query. Unlike FT-5219's persistent Bun.SQL client the process survives, and unlike FT-5226 the failure surfaces at `query()` time on an already-established connection — so a connect-time retry (`retryTransientConnect` wrapping `pool.connect()`) never engages. The pool's async idle-client `'error'` event is also unhandled by default, which turns the close into a process crash if no `uncaughtException` guard exists. + +**Workaround.** Keep the pool's idle timeout under the platform's idle-close window, and attach a pool error handler so the close is logged rather than fatal: + +```ts +const pool = new pg.Pool({ connectionString, idleTimeoutMillis: 5_000 }); +pool.on("error", (err) => console.error("pg pool idle client error", err)); +``` + +**Reproduction.** + +1. Deploy a Compute service holding a module-scope `pg.Pool` (default or 30 s `idleTimeoutMillis`) on a PPg direct connection, with a route that queries. +2. Hit the route → 200. Let it idle ≥ 30 s. +3. Hit again → one failure with `Connection terminated unexpectedly`; the following request → 200. + +**References.** + +- Fix in this repo: `resilientPool` in [`packages/1-prisma-cloud/1-extensions/target/src/prisma-next.ts`](packages/1-prisma-cloud/1-extensions/target/src/prisma-next.ts) (commit `0088520`) +- Related: [FT-5219](https://linear.app/prisma-company/issue/FT-5219) (same idle-close, persistent Bun.SQL client → 502 loop), [FT-5226](https://linear.app/prisma-company/issue/FT-5226) (same cold/idle family, deploy-time first connect) + +--- + +## Service-to-service HTTP gets ECONNRESET while the target cold-starts from scale-to-zero + +**Filed upstream:** [PRO-217](https://linear.app/prisma-company/issue/PRO-217/service-to-service-http-gets-econnreset-while-the-target-cold-starts) — _"Service-to-service HTTP gets ECONNRESET while the target cold-starts from scale-to-zero"_ +**Product:** Prisma Compute (ingress / scale-to-zero cold start) +**Version:** Prisma Compute, Bun `fetch` from a Next.js standalone SSR render; observed 2026-07-13 +**First hit:** `examples/store` — the storefront's SSR calls to the catalog and orders services' `*.ewr.prisma.build` endpoints +**Cost:** folded into the idle-500 diagnosis above; intermittent enough to first read as "the in-app browser is flaky" + +**Symptom.** An HTTP request from one Compute service to another intermittently fails with `ECONNRESET` — Bun reports `The socket connection was closed unexpectedly` with the target service's URL as `path`. It happens on the first request(s) after the target has been idle; a retry moments later succeeds. When the caller is an SSR page fanning out to several services, one reset is enough to 500 the whole page render. + +**Cause (observed, mechanism presumed).** The target service had scaled to zero. Instead of the edge holding the connection until the VM finishes booting (which it does do on most cold hits — those requests just take seconds), the connection is sometimes closed mid-establishment during the cold-start window, surfacing as a socket reset to the caller. Warm targets never reset. + +**Workaround.** No principled client-side fix for non-idempotent calls (blind retry could double-execute a write). Mitigations: + +- retry only requests that never reached the server / are idempotent reads; +- keep chatty targets warm — a scheduled ping (the `cron` shared module's 30 s trigger) masks the window for whatever it touches; +- warm the whole app with one request before a demo. + +An always-on / min-instances option on Compute services would remove the window; none exists today. + +**Reproduction.** + +1. Deploy two Compute services, A calling B over HTTP on each request to A. +2. Let B idle to scale-to-zero. +3. Hit A repeatedly right as B cold-starts → occasional `ECONNRESET` from A's fetch to B; warm B never resets. + +**References.** + +- Observed in `storefront` runtime logs (`app logs --project store --app storefront`): `code: 'ECONNRESET', path: 'https://….ewr.prisma.build/rpc/listProducts'` +- Related: [FT-5219](https://linear.app/prisma-company/issue/FT-5219) / FT-5226 (the DB faces of the same scale-to-zero/cold family) diff --git a/packages/0-framework/2-authoring/node/src/control.ts b/packages/0-framework/2-authoring/node/src/control.ts index 5f474a15..2f7ce8eb 100644 --- a/packages/0-framework/2-authoring/node/src/control.ts +++ b/packages/0-framework/2-authoring/node/src/control.ts @@ -69,7 +69,11 @@ export async function assemble(input: AssembleInput): Promise { await fs.promises.mkdir(bundleDir, { recursive: true }); await build({ - entry: [serviceModule], + // Named entry: tsdown derives the output filename from the entry key, so + // the bundle is service.mjs regardless of the module's own basename (a + // shared module's service file may not be named service.ts — the cron + // scheduler's is scheduler-service.mjs). + entry: { service: serviceModule }, outDir: bundleDir, format: 'esm', platform: 'node', diff --git a/packages/1-prisma-cloud/1-extensions/target/package.json b/packages/1-prisma-cloud/1-extensions/target/package.json index 6ecfa038..9c435c04 100644 --- a/packages/1-prisma-cloud/1-extensions/target/package.json +++ b/packages/1-prisma-cloud/1-extensions/target/package.json @@ -44,7 +44,8 @@ "alchemy": "2.0.0-beta.59", "arktype": "^2.2.3", "effect": "4.0.0-beta.92", - "pathe": "^2.0.3" + "pathe": "^2.0.3", + "pg": "8.21.0" }, "devDependencies": { "@prisma-next/target-postgres": "0.14.0", diff --git a/packages/1-prisma-cloud/1-extensions/target/src/prisma-next.ts b/packages/1-prisma-cloud/1-extensions/target/src/prisma-next.ts index 7cd55498..29679fbe 100644 --- a/packages/1-prisma-cloud/1-extensions/target/src/prisma-next.ts +++ b/packages/1-prisma-cloud/1-extensions/target/src/prisma-next.ts @@ -148,7 +148,10 @@ function isPnPostgresContract(value: unknown): value is PnPostgresContract { function buildClient(contract: C, url: string): Client { return pnPostgresRuntime>({ contractJson: contract.__cmp.contractJson, - pg: resilientPool(url), + // Explicit binding, NOT `pg: pool`: the bare form sniffs the pool with + // `instanceof`, which breaks whenever a bundle carries two copies of pg + // (the pool from one, the runtime's Pool class from the other). + binding: { kind: 'pgPool', pool: resilientPool(url) }, }); } @@ -161,8 +164,17 @@ function resilientPool(url: string): pg.Pool { const pool = new pg.Pool({ connectionString: normalizeSslMode(url), connectionTimeoutMillis: 20_000, - idleTimeoutMillis: 30_000, + // Prisma Postgres closes idle direct connections well under 30s + // (FT-5219). Discard idle clients first, or the first query after an + // idle spell grabs a dead socket and fails with "Connection terminated + // unexpectedly" — a 500, since it surfaces at query() time where + // retryTransientConnect (which wraps only connect()) can't help. + idleTimeoutMillis: 5_000, }); + // The server closing an idle pooled client emits an async 'error' on the + // pool; unhandled, that crashes the process. Log it — the pool already + // discards the dead client and reconnects on the next acquire. + pool.on('error', (err) => console.error('pg pool idle client error', err)); const acquire = pool.connect.bind(pool); pool.connect = blindCast< typeof pool.connect, diff --git a/packages/9-public/compose-prisma-cloud/package.json b/packages/9-public/compose-prisma-cloud/package.json index 3ccd99d5..f5887ccc 100644 --- a/packages/9-public/compose-prisma-cloud/package.json +++ b/packages/9-public/compose-prisma-cloud/package.json @@ -33,6 +33,7 @@ "arktype": "^2.2.3", "effect": "4.0.0-beta.92", "pathe": "^2.0.3", + "pg": "8.21.0", "postgres": "^3.4.9", "tsdown": "^0.22.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc758e01..667fbdef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,6 +101,176 @@ importers: specifier: ^6.0.3 version: 6.0.3 + examples/store: + dependencies: + '@effect/platform-bun': + specifier: 4.0.0-beta.92 + version: 4.0.0-beta.92(effect@4.0.0-beta.92) + '@prisma/compose': + specifier: workspace:0.1.0 + version: link:../../packages/9-public/compose + '@prisma/compose-prisma-cloud': + specifier: workspace:0.1.0 + version: link:../../packages/9-public/compose-prisma-cloud + '@store/catalog': + specifier: workspace:0.1.0 + version: link:modules/catalog + '@store/orders': + specifier: workspace:0.1.0 + version: link:modules/orders + '@store/promotions': + specifier: workspace:0.1.0 + version: link:modules/promotions + '@store/storefront': + specifier: workspace:0.1.0 + version: link:modules/storefront + alchemy: + specifier: 2.0.0-beta.59 + version: 2.0.0-beta.59(@effect/platform-bun@4.0.0-beta.92(effect@4.0.0-beta.92))(@effect/platform-node@4.0.0-beta.92(effect@4.0.0-beta.92)(ioredis@5.11.1))(@types/node@25.9.5)(@types/react@19.2.17)(effect@4.0.0-beta.92)(vite@8.1.2(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0))(vitest@4.1.9(@types/node@25.9.5)(vite@8.1.2(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)))(workerd@1.20260617.1)(ws@8.21.0) + arktype: + specifier: ^2.2.3 + version: 2.2.3 + effect: + specifier: 4.0.0-beta.92 + version: 4.0.0-beta.92 + devDependencies: + '@types/bun': + specifier: ^1.3.13 + version: 1.3.14 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + examples/store/modules/catalog: + dependencies: + '@prisma-next/postgres': + specifier: 0.14.0 + version: 0.14.0(typanion@3.14.0)(typescript@6.0.3) + arktype: + specifier: ^2.2.3 + version: 2.2.3 + pg: + specifier: 8.21.0 + version: 8.21.0 + devDependencies: + '@prisma/compose': + specifier: workspace:0.1.0 + version: link:../../../../packages/9-public/compose + '@prisma/compose-prisma-cloud': + specifier: workspace:0.1.0 + version: link:../../../../packages/9-public/compose-prisma-cloud + '@types/bun': + specifier: ^1.3.13 + version: 1.3.14 + tsdown: + specifier: ^0.22.3 + version: 0.22.3(typescript@6.0.3) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + examples/store/modules/orders: + dependencies: + '@prisma-next/postgres': + specifier: 0.14.0 + version: 0.14.0(typanion@3.14.0)(typescript@6.0.3) + '@store/catalog': + specifier: workspace:0.1.0 + version: link:../catalog + arktype: + specifier: ^2.2.3 + version: 2.2.3 + pg: + specifier: 8.21.0 + version: 8.21.0 + devDependencies: + '@prisma/compose': + specifier: workspace:0.1.0 + version: link:../../../../packages/9-public/compose + '@prisma/compose-prisma-cloud': + specifier: workspace:0.1.0 + version: link:../../../../packages/9-public/compose-prisma-cloud + '@types/bun': + specifier: ^1.3.13 + version: 1.3.14 + tsdown: + specifier: ^0.22.3 + version: 0.22.3(typescript@6.0.3) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + examples/store/modules/promotions: + dependencies: + '@store/catalog': + specifier: workspace:0.1.0 + version: link:../catalog + arktype: + specifier: ^2.2.3 + version: 2.2.3 + devDependencies: + '@prisma/compose': + specifier: workspace:0.1.0 + version: link:../../../../packages/9-public/compose + '@prisma/compose-prisma-cloud': + specifier: workspace:0.1.0 + version: link:../../../../packages/9-public/compose-prisma-cloud + '@types/bun': + specifier: ^1.3.13 + version: 1.3.14 + tsdown: + specifier: ^0.22.3 + version: 0.22.3(typescript@6.0.3) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + examples/store/modules/storefront: + dependencies: + '@prisma/compose': + specifier: workspace:0.1.0 + version: link:../../../../packages/9-public/compose + '@prisma/compose-prisma-cloud': + specifier: workspace:0.1.0 + version: link:../../../../packages/9-public/compose-prisma-cloud + '@store/catalog': + specifier: workspace:0.1.0 + version: link:../catalog + '@store/orders': + specifier: workspace:0.1.0 + version: link:../orders + arktype: + specifier: ^2.2.3 + version: 2.2.3 + next: + specifier: ^16.2.9 + version: 16.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: + specifier: 19.2.7 + version: 19.2.7 + react-dom: + specifier: 19.2.7 + version: 19.2.7(react@19.2.7) + devDependencies: + '@types/bun': + specifier: ^1.3.13 + version: 1.3.14 + '@types/node': + specifier: ^25.9.3 + version: 25.9.5 + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.0 + version: 19.2.3(@types/react@19.2.17) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.9 + version: 4.1.9(@types/node@25.9.5)(vite@8.1.2(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.6.1)(yaml@2.9.0)) + examples/storefront-auth: dependencies: '@effect/platform-bun': @@ -470,6 +640,9 @@ importers: pathe: specifier: ^2.0.3 version: 2.0.3 + pg: + specifier: 8.21.0 + version: 8.21.0 devDependencies: '@internal/tsdown-config': specifier: workspace:0.1.0 @@ -632,6 +805,9 @@ importers: pathe: specifier: ^2.0.3 version: 2.0.3 + pg: + specifier: 8.21.0 + version: 8.21.0 postgres: specifier: ^3.4.9 version: 3.4.9