Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/design/10-domains/local-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ SQLite test server remains a testing utility, not part of the dev loop.

### Postgres

The emulator is the ORM CLI's local Postgres (`prisma dev`), **one named, detached instance per `Database` resource** — instance names are derived from the app and database ids, so instances are isolated, discoverable (`prisma dev ls`), and survive across dev sessions for warm starts. Migrations are not special-cased: `OrmMigration` runs exactly as it does in a deploy, against the local URL — replay-only (ADR-0022 as revised), so it applies committed migrations and never synthesizes schema. Dev-loop schema iteration therefore happens through the ORM's own `prisma db update`, run directly against the emulator database: `db update` moves the database and its marker to the current contract, and the pipeline's migration step no-ops because the marker matches the target. A dev run against a database that was neither updated nor covered by a planned migration hits the same structured refusal a deploy would, naming both exits (`prisma db update` to iterate, `contract emit` + `migration plan` to author the path). `PgWarm` is near-instant locally and is kept (not stubbed) so the provider set stays uniform.
The emulator is the ORM CLI's local Postgres (`prisma dev`), **one named, detached instance per `Database` resource** — instance names are derived from the app and database ids, so instances are isolated, discoverable (`prisma dev ls`), and survive across dev sessions for warm starts. Migrations are not special-cased: `OrmMigration` runs exactly as it does in a deploy, against the local URL — replay-only (ADR-0022 as revised), so it applies committed migrations and never synthesizes schema. At reconcile time it reloads the emitted `contract.json` identified by `prisma.config.ts`, attests its `storageHash` against the compact contract identity persisted in deploy state, and only then opens the database. Dev-loop schema iteration therefore happens through the ORM's own `prisma db update`, run directly against the emulator database: `db update` moves the database and its marker to the current contract, and the pipeline's migration step no-ops because the marker matches the target. A dev run against a database that was neither updated nor covered by a planned migration hits the same structured refusal a deploy would, naming both exits (`prisma db update` to iterate, `contract emit` + `migration plan` to author the path). `PgWarm` is near-instant locally and is kept (not stubbed) so the provider set stays uniform.

### Buckets: a disk-backed S3 emulator

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,10 @@ The two ends of the resource pull in opposite directions, so they enter by
different doors. The **contract** is *consumed*: it types and wires the resource
and gives the deploy the schema version to migrate to. The
**`prisma.config.ts`** is *located*, by path only — deploy-only metadata
the migration step reads to find the migrations directory. The app build never
imports it, because importing it would pull Prisma ORM's CLI, migration engine,
and source providers into the user's bundle. One contract per database.
the migration step reads to find the emitted `contract.json` and the
migrations directory. The app build never imports it, because importing it
would pull Prisma ORM's CLI, migration engine, and source providers into the
user's bundle. One contract per database.

At deploy, the lowering gains a migration step per `postgres` resource. Its
target is a **ref** — `{ hash, invariants }` — and the live database carries a
Expand Down Expand Up @@ -80,9 +81,11 @@ otherwise → migrate (replay
The ref comes from the resource's optional `targetRef` (naming a
`migrations/app/refs/<name>.json` file), or defaults to the head: the emitted
contract's hash with zero invariants. The tracked migration resource is keyed on
the ref's identity (hash plus sorted invariants), so a data-only change still
produces a distinct deploy step. Synthesized diff-and-apply (`dbUpdate`) is never
run against a deployed database — only `migrate` is.
the ref's identity (hash plus sorted invariants), and its persisted props carry
only compact contract identity plus the config/migrations paths — not the full
emitted contract — so a data-only change still produces a distinct deploy step
without pushing `contract.json` into Alchemy state. Synthesized diff-and-apply
(`dbUpdate`) is never run against a deployed database — only `migrate` is.

Bare `postgres()` is unchanged: the untyped escape hatch, the `any` of data
deps, the same role `http()` plays for communication.
Expand Down Expand Up @@ -111,19 +114,21 @@ own subpath entry, never re-exported from the index — so a service that opts o
never loads `@prisma/orm-postgres` or `pg` at runtime.

**Consume the contract; locate the config.** The runtime and the type system
only need to *consume* the contract: `contract.json` (the data the framework
hands the runtime at *hydrate* — the boot-time step that builds each
dependency's client) and `contract.d.ts` (types), both lightweight and
importable into the app build with no deploy machinery attached. The deploy migration step needs to
*locate* the config — the `prisma.config.ts` from which Prisma ORM resolves
the migrations directory — but it needs only the **path**, a string, read at
deploy time. Passing the config as a path rather than an import is what keeps
Prisma ORM's CLI and migration engine out of the user's bundle while still
giving the deploy lowering what it needs. A single contract is Prisma ORM's
mainline single-space model, so the user authors one contract that serves every
consuming module. Each consumer sees the full contract type; per-consumer
least-privilege slices are the deferred multi-contract extension (see
Alternatives).
need to *consume* the contract: `contract.json` (the data the framework hands
the runtime at *hydrate* — the boot-time step that builds each dependency's
client) and `contract.d.ts` (types), both lightweight and importable into the
app build with no deploy machinery attached. The deploy migration step needs to
*locate* the config — the `prisma.config.ts` from which Prisma ORM resolves the
emitted `contract.json` output and the migrations directory — but it needs only
the **path**, a string, read at deploy time. Passing the config as a path
rather than an import is what keeps Prisma ORM's CLI and migration engine out
of the user's bundle while still giving the deploy lowering what it needs. The
migration resource therefore persists only a compact attestation of the current
declared contract and reloads the full emitted contract at reconcile time. A
single contract is Prisma ORM's mainline single-space model, so the user
authors one contract that serves every consuming module. Each consumer sees the
full contract type; per-consumer least-privilege slices are the deferred
multi-contract extension (see Alternatives).

**Schema checking is a build/deploy-time job, not a runtime one.** The
authoritative check is the deploy. `migrate` walks the authored graph from the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,11 @@ or invalidity.
run against a contract the runtime's validator would reject — the
storage-hash check at wiring remains the compatibility check that matters
(ADR-0022).
3. `OrmMigration` and the deploy lowering are untouched: provisioning
`postgres({ name, contract, config })` still migrates at deploy. An app
owning its client gets framework-run migrations with no operator step.
3. `postgres({ name, contract, config })` still migrates at deploy. The
migration resource now persists only compact contract identity and reloads
the emitted contract artifact from `prisma.config.ts` at reconcile time, so
an app owning its client still gets framework-run migrations with no
operator step.

## Alternatives considered

Expand Down
9 changes: 7 additions & 2 deletions docs/guides/building-an-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,8 @@ deps: { db: postgres(catalogData) }

An options object is the resource end — the module that owns the database
provisions it, naming the `prisma.config.ts` path (relative to the
module file) so the deploy can find `migrations/`:
module file) so the deploy can reload the emitted `contract.json` and find
`migrations/`:

```ts
const db = provision(
Expand All @@ -191,7 +192,11 @@ const db = provision(
```

Because both ends share the contract value, the deploy refuses to wire a
service against a database whose schema doesn't match.
service against a database whose schema doesn't match. The migration resource
persists only compact contract identity in deploy state; the full emitted
contract is reloaded from `prisma.config.ts` at reconcile time. If that
artifact is missing, unreadable, or no longer matches the declared contract,
the deploy fails before touching the database.
[`examples/orm-demo`](../../examples/orm-demo/) is the minimal working
version;
[`examples/store/modules/catalog`](../../examples/store/modules/catalog/) is
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -610,7 +610,7 @@ describe("prismaCloud().nodes['postgres'] — the resource descriptor", () => {
'prisma.config.ts',
);

test('default stage: the Database attaches the default Branch; the migration runs on its warmed url', async () => {
test('default stage: the Database attaches the default Branch; the migration persists compact contract attestation on its warmed url', async () => {
await withEnv({}, async () => {
const target = prismaCloud({ workspaceId: 'ws_1' });
const node = postgres({
Expand Down Expand Up @@ -640,11 +640,56 @@ describe("prismaCloud().nodes['postgres'] — the resource descriptor", () => {
],
]);
const [migrateId, migrateProps] = recorded.pnMigrate[before.migrate] ?? ['', {}];
const persisted = migrateProps as Record<string, unknown>;
expect(migrateId).toBe('pndata-migrate');
expect((migrateProps as { url: unknown }).url).toBe('postgres://pndata-conn');
expect(persisted['url']).toBe('postgres://pndata-conn');
expect(persisted['currentContractHash']).toBe(widgetContractJson.storage.storageHash);
expect(persisted['targetHash']).toBe(widgetContractJson.storage.storageHash);
expect(persisted['migrationsDir']).toBe(path.join(path.dirname(widgetConfig), 'migrations'));
expect(persisted['configPath']).toBe(widgetConfig);
expect(persisted['packHeadRefHashes']).toEqual([]);
expect('contractJson' in persisted).toBe(false);
expect(result.entities).toEqual([{ kind: 'postgres-database', id: 'pndata-db#cloud-id' }]);
});
});

test('a contract larger than 100 KB does not enlarge newly persisted migration props', async () => {
await withEnv({}, async () => {
const oversizedProof = 'x'.repeat(110_001);
expect(oversizedProof.length).toBeGreaterThan(100_000);
const target = prismaCloud({ workspaceId: 'ws_1' });
const lower = async (contractJson: unknown) => {
const node = postgres({
name: 'oversized',
contract: dataContract(contractJson),
config: widgetConfig,
});
const ctx = {
id: 'oversized',
node,
graph: { edges: [], nodes: [] },
application: {
projectId: 'shop-project#cloud-id',
branchId: undefined,
defaultBranchId: 'br_default',
branchless: false,
},
} as unknown as LowerContext;
const before = recorded.pnMigrate.length;
await runAsync<LoweredResult>(resourceDescriptorOf(target, 'postgres')(ctx));
return recorded.pnMigrate[before]?.[1];
};

const compact = await lower(widgetContractJson);
const oversized = await lower({ ...widgetContractJson, oversizedProof });
const compactJson = JSON.stringify(compact);
const oversizedJson = JSON.stringify(oversized);

expect(oversizedJson).toBe(compactJson);
expect(oversizedJson).not.toContain(oversizedProof);
expect('contractJson' in ((oversized ?? {}) as Record<string, unknown>)).toBe(false);
});
});
});

describe("prismaCloud().nodes['credentials'] — the resource descriptor", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { describe, expect, test } from 'bun:test';
import * as path from 'node:path';
import {
loadContractJson,
type PnExtensionPack,
packHeadRefHashes,
resolveMigrationsDir,
Expand All @@ -19,6 +20,9 @@ import {
GADGET_PACK_ID,
gadgetPack,
} from './fixtures/packed-contract/pack.ts';
import widgetContractJson from './fixtures/widget-contract/emitted/contract.json' with {
type: 'json',
};

const widgetConfig = path.join(
import.meta.dir,
Expand Down Expand Up @@ -50,6 +54,26 @@ describe('resolveOrmConfig', () => {
expect(project.extensionPacks).toEqual([]);
});

test('resolves the emitted contract artifact path from the config output', async () => {
const project = await resolveOrmConfig(widgetConfig);
expect(project.contractArtifactPath).toBe(
path.join(path.dirname(widgetConfig), '..', 'emitted', 'contract.json'),
);
expect(path.isAbsolute(project.contractArtifactPath)).toBe(true);
});

test('without explicit output, the emitted contract path defaults next to the contract source', async () => {
const project = await resolveOrmConfig(packedConfig);
expect(project.contractArtifactPath).toBe(
path.join(path.dirname(packedConfig), 'contract.json'),
);
});

test('loadContractJson reads the emitted contract the config identifies', async () => {
const project = await resolveOrmConfig(widgetConfig);
expect(await loadContractJson(project.contractArtifactPath)).toEqual(widgetContractJson);
});

test('surfaces declared extension packs with their contract-space heads', async () => {
const project = await resolveOrmConfig(packedConfig);
expect(project.extensionPacks.map((p) => p.id)).toEqual([GADGET_PACK_ID]);
Expand Down
Loading
Loading