Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
73 changes: 73 additions & 0 deletions examples/multi-extension-monorepo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# multi-extension-monorepo

Worked example: a Prisma Next application that depends on **two** internal
contract-space packages — `audit` and `feature-flags` — plus its own
application schema. Exercises the framework's per-space planner / runner /
verifier with multiple extensions composed into a single application.

## What this demonstrates

Prisma Next's contract-space mechanism is symmetric across origin: a
contract space contributed by an installed extension package, a published
extension on npm, and an internal monorepo package all flow through the
same descriptor surface. The framework reads each `extensionPacks` entry's
descriptor at authoring time, emits pinned per-space artefacts into the
user's repo, and applies migrations from each space in cross-space order
(extensions first, app last) inside a single transaction.

This example exercises that property end-to-end against PGlite (the
embedded Postgres-compatible engine the framework uses for tests). Two
trivial "internal extensions" each declare:

- a one-table contract,
- a single baseline migration that creates the table,
- a stable `<package>:create-<table>-v1` invariantId.

The aggregator (the example application itself) declares its own `User`
table and lists both internal extensions in its
`prisma-next.config.ts`-equivalent. After `migrate` + `apply`:

- pinned artefacts land at `migrations/audit/{contract.json,contract.d.ts,refs/head.json}`
and `migrations/feature-flags/...`;
- migration directories at `migrations/audit/<dirName>/` and
`migrations/feature-flags/<dirName>/`;
- the marker table has three rows (`app`, `audit`, `feature-flags`),
each carrying the expected core hash and applied invariants.

## Layout

This example is shipped as a single workspace package for ergonomic
reasons (the framework's package layering treats `examples/*` as the
top-level glob — see `pnpm-workspace.yaml`). The internal `packages/*`
subdirectories play the role of separately-published packages in a real
monorepo: each has its own descriptor module exporting an
`SqlControlExtensionDescriptor` exactly as a published extension would.
The application code under `app/` consumes them via relative imports
where it would consume them via `workspace:*` dependencies in a real
monorepo. The framework code path is identical either way — the
descriptor module is the only seam.

```text
examples/multi-extension-monorepo/
├── packages/
│ ├── audit/ ← internal "package" #1
│ │ ├── constants.ts
│ │ ├── contract.ts
│ │ ├── migrations.ts
│ │ └── control.ts ← `auditExtensionDescriptor`
│ └── feature-flags/ ← internal "package" #2
│ ├── constants.ts
│ ├── contract.ts
│ ├── migrations.ts
│ └── control.ts ← `featureFlagsExtensionDescriptor`
├── app/
│ └── contract.ts ← application contract (declares `User`)
└── test/
└── multi-space.e2e.integration.test.ts
```

## Running

```sh
pnpm --filter @prisma-next/example-multi-extension-monorepo test
```
46 changes: 46 additions & 0 deletions examples/multi-extension-monorepo/app/contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* Application contract for the multi-extension-monorepo example.
*
* Declares one user-owned `User` table, completely independent of the
* tables contributed by the two internal extension packages
* (`audit_event` from `packages/audit`, `feature_flag` from
* `packages/feature-flags`). After `migrate` + `apply` runs against a
* fresh database, all three tables coexist in `public`, and the marker
* table holds three rows — one per contract space.
*/

import { type Contract, coreHash, profileHash } from '@prisma-next/contract/types';
import type { SqlStorage } from '@prisma-next/sql-contract/types';

export const APP_USER_TABLE = 'app_user' as const;

const APP_CONTRACT_HASH = coreHash('sha256:multi-extension-monorepo-app-v1');
const APP_PROFILE_HASH = profileHash('sha256:multi-extension-monorepo-app-profile-v1');

export const appContract: Contract<SqlStorage> = {
target: 'postgres',
targetFamily: 'sql',
profileHash: APP_PROFILE_HASH,
storage: {
storageHash: APP_CONTRACT_HASH,
tables: {
[APP_USER_TABLE]: {
columns: {
id: { codecId: 'pg/text@1', nativeType: 'text', nullable: false },
email: { codecId: 'pg/text@1', nativeType: 'text', nullable: false },
},
primaryKey: { columns: ['id'] },
uniques: [],
indexes: [],
foreignKeys: [],
},
},
},
roots: {},
models: {},
capabilities: {},
extensionPacks: {},
meta: {},
};

export const APP_CONTRACT_HASH_VALUE = APP_CONTRACT_HASH;
4 changes: 4 additions & 0 deletions examples/multi-extension-monorepo/biome.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
"extends": "//"
}
33 changes: 33 additions & 0 deletions examples/multi-extension-monorepo/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "@prisma-next/example-multi-extension-monorepo",
"version": "0.0.1",
"private": true,
"type": "module",
"engines": {
"node": ">=24"
},
"description": "Worked example: monorepo with two internal contract-space packages and an aggregating app. Exercises the framework's per-space planner/runner/verifier with multiple extensions in one application (project: extension-contract-spaces, M4 / TC-8 / spec AC4).",
"scripts": {
"test": "vitest run --config vitest.config.ts",
"typecheck": "tsc --project tsconfig.json --noEmit",
"lint": "biome check . --error-on-warnings"
},
"dependencies": {
"@prisma-next/adapter-postgres": "workspace:*",
"@prisma-next/cli": "workspace:*",
"@prisma-next/contract": "workspace:*",
"@prisma-next/driver-postgres": "workspace:*",
"@prisma-next/family-sql": "workspace:*",
"@prisma-next/framework-components": "workspace:*",
"@prisma-next/migration-tools": "workspace:*",
"@prisma-next/sql-contract": "workspace:*",
"@prisma-next/target-postgres": "workspace:*"
},
"devDependencies": {
"@prisma-next/test-utils": "workspace:*",
"@prisma-next/tsconfig": "workspace:*",
"@types/node": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
}
}
17 changes: 17 additions & 0 deletions examples/multi-extension-monorepo/packages/audit/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Static identifiers for the `audit` internal contract-space package.
*
* Mirrors the convention real extensions follow (see
* `packages/3-extensions/test-contract-space/src/core/constants.ts`):
* a stable space id used as the `migrations/<space-id>/` directory
* name, a stable invariantId for the baseline op, and a stable
* baseline-migration directory name.
*/

export const AUDIT_SPACE_ID = 'audit' as const;

export const AUDIT_EVENT_TABLE = 'audit_event' as const;

export const AUDIT_BASELINE_INVARIANT_ID = 'audit:create-audit_event-v1' as const;

export const AUDIT_BASELINE_MIGRATION_NAME = '20260601T0000_create_audit_event' as const;
44 changes: 44 additions & 0 deletions examples/multi-extension-monorepo/packages/audit/contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { computeStorageHash } from '@prisma-next/contract/hashing';
import { type Contract, coreHash, profileHash } from '@prisma-next/contract/types';
import type { SqlStorage } from '@prisma-next/sql-contract/types';
import { AUDIT_EVENT_TABLE } from './constants';

const TARGET = 'postgres' as const;
const TARGET_FAMILY = 'sql' as const;

const storageBody = {
tables: {
[AUDIT_EVENT_TABLE]: {
columns: {
id: { codecId: 'pg/text@1', nativeType: 'text', nullable: false },
actor: { codecId: 'pg/text@1', nativeType: 'text', nullable: false },
action: { codecId: 'pg/text@1', nativeType: 'text', nullable: false },
},
primaryKey: { columns: ['id'] },
uniques: [],
indexes: [],
foreignKeys: [],
},
},
};

export const AUDIT_STORAGE_HASH = computeStorageHash({
target: TARGET,
targetFamily: TARGET_FAMILY,
storage: storageBody,
});

export const auditContract: Contract<SqlStorage> = {
target: TARGET,
targetFamily: TARGET_FAMILY,
roots: {},
models: {},
capabilities: {},
extensionPacks: {},
meta: {},
profileHash: profileHash('audit-extension-profile-v1'),
storage: {
...storageBody,
storageHash: coreHash(AUDIT_STORAGE_HASH),
},
};
36 changes: 36 additions & 0 deletions examples/multi-extension-monorepo/packages/audit/control.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* Control-plane descriptor for the internal `audit` contract-space
* package. Exposes a `contractSpace` so the framework treats the
* audit-event scaffolding as a first-class schema contribution
* alongside the application's own schema.
*/

import type { Contract } from '@prisma-next/contract/types';
import type { SqlControlExtensionDescriptor } from '@prisma-next/family-sql/control';
import type { ContractSpace } from '@prisma-next/framework-components/control';
import type { SqlStorage } from '@prisma-next/sql-contract/types';
import { AUDIT_SPACE_ID } from './constants';
import { auditContract } from './contract';
import { auditBaselineMigration, auditHeadRef } from './migrations';

const auditContractSpace: ContractSpace<Contract<SqlStorage>> = {
contractJson: auditContract,
migrations: [auditBaselineMigration],
headRef: auditHeadRef,
};

const auditExtensionDescriptor: SqlControlExtensionDescriptor<'postgres'> = {
kind: 'extension' as const,
id: AUDIT_SPACE_ID,
familyId: 'sql' as const,
targetId: 'postgres' as const,
version: '0.0.1',
contractSpace: auditContractSpace,
create: () => ({
familyId: 'sql' as const,
targetId: 'postgres' as const,
}),
};

export { auditExtensionDescriptor };
export default auditExtensionDescriptor;
80 changes: 80 additions & 0 deletions examples/multi-extension-monorepo/packages/audit/migrations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import type { SqlMigrationPlanOperation } from '@prisma-next/family-sql/control';
import type {
ContractSpaceHeadRef,
MigrationPackage,
MigrationPlanOperation,
} from '@prisma-next/framework-components/control';
import { computeMigrationHash } from '@prisma-next/migration-tools/hash';
import {
AUDIT_BASELINE_INVARIANT_ID,
AUDIT_BASELINE_MIGRATION_NAME,
AUDIT_EVENT_TABLE,
} from './constants';
import { AUDIT_STORAGE_HASH, auditContract } from './contract';

type PostgresTargetDetails = {
readonly schema: string;
readonly objectType: 'table';
readonly name: string;
};

const createAuditEventOp: SqlMigrationPlanOperation<unknown> = {
id: `audit.create-${AUDIT_EVENT_TABLE}`,
label: `Create table "${AUDIT_EVENT_TABLE}"`,
operationClass: 'additive',
invariantId: AUDIT_BASELINE_INVARIANT_ID,
target: {
id: 'postgres',
details: {
schema: 'public',
objectType: 'table',
name: AUDIT_EVENT_TABLE,
} satisfies PostgresTargetDetails,
},
precheck: [],
execute: [
{
description: `Create table "${AUDIT_EVENT_TABLE}"`,
sql: `CREATE TABLE IF NOT EXISTS public."${AUDIT_EVENT_TABLE}" (
"id" text NOT NULL PRIMARY KEY,
"actor" text NOT NULL,
"action" text NOT NULL
)`,
},
],
postcheck: [],
};

const auditBaselineOps: readonly MigrationPlanOperation[] = [createAuditEventOp];

export const AUDIT_BASELINE_INVARIANTS: readonly string[] = (() => {
const ids = auditBaselineOps
.map((op) => op.invariantId)
.filter((id): id is string => typeof id === 'string');
return [...new Set(ids)].sort();
})();

const baselineMetadataWithoutHash: Omit<MigrationPackage['metadata'], 'migrationHash'> = {
from: null,
to: AUDIT_STORAGE_HASH,
fromContract: null,
toContract: auditContract,
hints: { used: [], applied: [], plannerVersion: '2.0.0' },
labels: [],
providedInvariants: AUDIT_BASELINE_INVARIANTS,
createdAt: '2026-06-01T00:00:00.000Z',
};

export const auditBaselineMigration: MigrationPackage = {
dirName: AUDIT_BASELINE_MIGRATION_NAME,
metadata: {
...baselineMetadataWithoutHash,
migrationHash: computeMigrationHash(baselineMetadataWithoutHash, auditBaselineOps),
},
ops: auditBaselineOps,
};

export const auditHeadRef: ContractSpaceHeadRef = {
hash: AUDIT_STORAGE_HASH,
invariants: AUDIT_BASELINE_INVARIANTS,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Static identifiers for the `feature-flags` internal contract-space
* package. Same shape as `packages/audit/constants.ts`; the duplication
* is intentional — each "internal package" owns its own identifiers.
*/

export const FEATURE_FLAGS_SPACE_ID = 'feature-flags' as const;

export const FEATURE_FLAG_TABLE = 'feature_flag' as const;

export const FEATURE_FLAGS_BASELINE_INVARIANT_ID = 'feature-flags:create-feature_flag-v1' as const;

export const FEATURE_FLAGS_BASELINE_MIGRATION_NAME = '20260601T0000_create_feature_flag' as const;
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { computeStorageHash } from '@prisma-next/contract/hashing';
import { type Contract, coreHash, profileHash } from '@prisma-next/contract/types';
import type { SqlStorage } from '@prisma-next/sql-contract/types';
import { FEATURE_FLAG_TABLE } from './constants';

const TARGET = 'postgres' as const;
const TARGET_FAMILY = 'sql' as const;

const storageBody = {
tables: {
[FEATURE_FLAG_TABLE]: {
columns: {
key: { codecId: 'pg/text@1', nativeType: 'text', nullable: false },
enabled: { codecId: 'pg/bool@1', nativeType: 'boolean', nullable: false },
},
primaryKey: { columns: ['key'] },
uniques: [],
indexes: [],
foreignKeys: [],
},
},
};

export const FEATURE_FLAGS_STORAGE_HASH = computeStorageHash({
target: TARGET,
targetFamily: TARGET_FAMILY,
storage: storageBody,
});

export const featureFlagsContract: Contract<SqlStorage> = {
target: TARGET,
targetFamily: TARGET_FAMILY,
roots: {},
models: {},
capabilities: {},
extensionPacks: {},
meta: {},
profileHash: profileHash('feature-flags-extension-profile-v1'),
storage: {
...storageBody,
storageHash: coreHash(FEATURE_FLAGS_STORAGE_HASH),
},
};
Loading
Loading