Skip to content

Commit dc72201

Browse files
wmadden-electricwmaddenclaude
authored
TML-2852: enums become first-class in application code — typed I/O, db.enums, declaration-order ORDER BY (#769)
## At a glance In the demo's own contract, `Post` carries a `Priority` enum — and the column is typed as its value union everywhere you touch it: ```ts const Priority = enumType('Priority', pgText, member('Low', 'low'), member('High', 'high'), member('Urgent', 'urgent')); // declaration order ≠ lexical // on a real model: Post { …, priority: field.namedType(Priority) } // READ — priority is 'low' | 'high' | 'urgent' (not string), sorted by DECLARATION order getPostsByPriority() // → rows ordered low, low, high, urgent (not lexical) // WRITE — only member values compile; 'nope' is a compile error, and the CHECK constraint rejects it at the DB // INTROSPECT at runtime db.enums.public.Priority.values // ['low', 'high', 'urgent'] (ordered, literal-typed) db.enums.public.Priority.members.High // 'high' ``` This runs end-to-end against PGlite in `examples/prisma-next-demo` — typed read, `db.enums.<ns>`, declaration-order `ORDER BY`, and the slice-2 `CHECK` rejecting out-of-union writes. ## What this decides An enum becomes a **first-class application concept**: its value union flows into the static read/write types of **both** query lanes, `db.enums.<ns>.<Name>` exposes it at runtime, and `ORDER BY` on an enum column sorts by **declaration order**. It works for **text and int-backed** enums and in **both authoring forms** (definition *and* factory `defineContract`). Built on the merged substrate (slice 1) and check-constraint enforcement (slice 2), and lands **additively** — PSL `enum` stays native until the cutover, so only `enumType`-authored contracts exercise it. ## How it builds up 1. **Typed I/O (R4/R5)** — narrow the codec type by the field's `valueSet` to the value union, on **both paths**: the authored `Definition` (no-emit) and the **emitted `contract.d.ts`** — the emitter resolves a field's `valueSet` ref to the enum's member-value union, codec-agnostically (text and int). Both lanes inherit it through the field-output typemap; **non-enum fields are unchanged**. 2. **`db.enums.<ns>.<Name>` (R6)** — a runtime, literal-typed accessor (`.values` / `.members.X` / `.has` / `.nameOf` / `.ordinalOf`) built from that namespace's domain enums. Enums are **lane-agnostic contract metadata**, so `db.enums` lives on the **`db` facade** alongside `transaction` / `prepare` / `raw` / `context` (decided with the query team) — a namespace-keyed map projected per target exactly like `db.sql` / `db.orm`. It matches the IR (`domain.namespaces[ns].enum`) and lets the same enum name in two namespaces resolve independently. Unbound-namespace targets (sqlite/mongo) get `db.enums.<Name>` via the existing per-facade projection. Because enums sit on the facade rather than adjacent to models, no reserved-name guard is needed — a model named `enums` no longer collides. 3. **Declaration-order `ORDER BY` (R8, Postgres)** — renders `array_position(ARRAY[…]::text[], col)` over the value-set's ordered values. 4. **Factory-form authoring** — the new enum is authorable in the demo's *real* factory-form contract (a top-level `enums` key threaded through the factory overload, mirroring the definition form), not only the definition form. ## Scope **Additive / dark.** PSL `enum` keeps lowering to native (the repoint is the cutover, TML-2853); member defaults are TML-2855; non-Postgres `ORDER BY` (MySQL `FIELD(...)` / SQLite `CASE`) is future. Existing fixtures are byte-identical apart from the demo. ## CI note The repo-wide `pnpm typecheck` is red on **inherited `Cannot find module` subpath errors** (`/contract-builder`, `/migration`, `/adapter`, `/aggregate`, `/constants`) that reproduce on clean `origin/main` — a separate main-health issue, not introduced here. ## Alternatives considered - **Narrow from the emitted contract JSON** rather than the authored `Definition` — rejected: emission widens the value-set to `string[]`, erasing the literals. - **A bespoke "enum codec"** — rejected: every field already carries a codec; the enum is a `valueSet` restriction layered on top. - **A dedicated `orderByDeclarationOrder()` API** — rejected: ordering an enum column by declaration order should just work; the renderer handles it implicitly. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **New Features** * Added enum type definitions with first-class support for enum-backed model fields in SQL contracts. * Enum fields now narrow to literal value unions (e.g., `'user' | 'admin'` instead of generic `string`) in both read and write operations, with compile-time type checking. * Exposed enum metadata and lookup utilities (values list, names, member maps, membership/ordinal checks) on the client facade under `db.enums.<namespace>.<EnumName>` (lane-agnostic namespace-keyed map). * Enum-backed ORDER BY now respects enum declaration order rather than lexical order. * Added support for both string and numeric enum values. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: Will Madden <madden@prisma.io> Co-authored-by: Will Madden <madden@prisma.io> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 890d102 commit dc72201

58 files changed

Lines changed: 3454 additions & 211 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

examples/prisma-next-demo/prisma/contract.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
import pgvector from '@prisma-next/extension-pgvector/pack';
2-
import { defineContract, rel } from '@prisma-next/postgres/contract-builder';
2+
import { defineContract, enumType, member, rel } from '@prisma-next/postgres/contract-builder';
3+
4+
const pgText = { codecId: 'pg/text@1', nativeType: 'text' } as const;
5+
6+
const Priority = enumType(
7+
'Priority',
8+
pgText,
9+
member('Low', 'low'),
10+
member('High', 'high'),
11+
member('Urgent', 'urgent'),
12+
);
313

414
export const contract = defineContract(
515
{
@@ -27,13 +37,15 @@ export const contract = defineContract(
2737
id: field.id.uuidv4(),
2838
title: field.text(),
2939
userId: field.uuid(),
40+
priority: field.namedType(Priority),
3041
createdAt: field.temporal.createdAt(),
3142
updatedAt: field.temporal.updatedAt(),
3243
embedding: field.namedType(types.Embedding1536).optional(),
3344
},
3445
});
3546

3647
return {
48+
enums: { Priority },
3749
types,
3850
models: {
3951
User: User.relations({

examples/prisma-next-demo/src/prisma-no-emit/context.ts

Lines changed: 12 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,24 @@
1-
import postgresAdapter from '@prisma-next/adapter-postgres/runtime';
2-
import postgresDriver from '@prisma-next/driver-postgres/runtime';
31
import pgvector from '@prisma-next/extension-pgvector/runtime';
4-
import { SqlContractSerializer } from '@prisma-next/family-sql/ir';
5-
import { sql as sqlBuilder } from '@prisma-next/sql-builder/runtime';
2+
import postgres from '@prisma-next/postgres/runtime';
63
import { orm } from '@prisma-next/sql-orm-client';
74
import type { Runtime } from '@prisma-next/sql-runtime';
8-
import { createExecutionContext, createSqlExecutionStack } from '@prisma-next/sql-runtime';
9-
import postgresTarget from '@prisma-next/target-postgres/runtime';
105
import { contract } from '../../prisma/contract';
116
import { PostCollection, UserCollection } from '../orm-client/collections';
127

13-
export const stack = createSqlExecutionStack({
14-
target: postgresTarget,
15-
adapter: postgresAdapter,
16-
driver: postgresDriver,
17-
extensionPacks: [pgvector],
18-
});
8+
// No-emit flow: hand the TypeScript-authored contract straight to the
9+
// `postgres()` facade with deferred binding (no url at construction). The
10+
// facade owns stack/context/sql/enums, so the demo no longer hand-wires them.
11+
export const db = postgres<typeof contract>({ contract, extensions: [pgvector] });
1912

20-
// The no-emit path passes the TS-authored contract directly; the
21-
// deserializer's method-level type parameter recovers the literal-
22-
// typed contract shape (from the generated `contract.d.ts`) so
23-
// downstream DSL calls keep their precise types.
24-
const validatedContract = new SqlContractSerializer().deserializeContract<typeof contract>(
25-
contract,
26-
);
27-
28-
export const context = createExecutionContext({
29-
contract: validatedContract,
30-
stack,
31-
});
32-
33-
export const sql = sqlBuilder<typeof contract>({
34-
context,
35-
rawCodecInferer: { inferCodec: () => 'pg/text' },
36-
}).public;
13+
export const context = db.context;
14+
export const stack = db.stack;
15+
export const enums = db.enums;
16+
export const sql = db.sql.public;
3717

3818
export function createOrmClient(runtime: Runtime) {
39-
// The no-emit contract types its domain namespaces loosely, so narrow the
40-
// `public` facet with a runtime guard rather than a cast.
19+
// The demo builds runtimes externally (custom pool/middleware) and passes
20+
// them in, so the ORM client is built against that runtime via the `orm()`
21+
// builder rather than the facade's own lazily-bound `db.orm`.
4122
const client = orm({
4223
runtime,
4324
context,
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { Runtime } from '@prisma-next/sql-runtime';
2+
import { enums, sql } from './context';
3+
4+
/**
5+
* Reads posts ordered by their `Priority` enum column. The enum's declaration
6+
* order (low -> high -> urgent) drives the sort, not lexical order, so the feed
7+
* surfaces the lowest-priority posts first.
8+
*/
9+
export async function getPostsByPriority(runtime: Runtime) {
10+
const rows = await runtime.execute(
11+
sql.post.select('id', 'title', 'priority').orderBy('priority').orderBy('id').build(),
12+
);
13+
return rows;
14+
}
15+
16+
/**
17+
* Returns the declaration-ordered runtime surface for the `Priority` enum via
18+
* the `db.enums` facade member (`enums.public.Priority`), demonstrating that
19+
* the value tuple and helpers are reachable as lane-agnostic contract metadata.
20+
*/
21+
export function getPriorityEnum() {
22+
// The no-emit contract types its domain namespaces loosely (index
23+
// signature), so `enums['public']` is reached with bracket access and a
24+
// runtime guard rather than a cast — the same shape `createOrmClient` uses.
25+
const publicEnums = enums['public'];
26+
if (publicEnums === undefined) {
27+
throw new Error("Contract is missing the 'public' namespace enums");
28+
}
29+
return publicEnums.Priority;
30+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { instantiateExecutionStack } from '@prisma-next/framework-components/execution';
2+
import { createRuntime, type Runtime } from '@prisma-next/sql-runtime';
3+
import { timeouts, withDevDatabase } from '@prisma-next/test-utils';
4+
import { Pool } from 'pg';
5+
import { describe, expect, expectTypeOf, it } from 'vitest';
6+
import { contract } from '../prisma/contract';
7+
import { context, sql, stack } from '../src/prisma-no-emit/context';
8+
import { getPostsByPriority, getPriorityEnum } from '../src/prisma-no-emit/priority-feed';
9+
import { initTestDatabase } from './utils/control-client';
10+
11+
const authorId = '00000000-0000-0000-0000-000000000001';
12+
13+
// Posts whose `Priority` enum values are deliberately out of declaration order
14+
// so the ORDER BY assertion below is meaningful. Post ids are uuids so the
15+
// secondary sort key is stable.
16+
const seed = [
17+
{
18+
id: '10000000-0000-0000-0000-00000000000a',
19+
title: 'Ship it',
20+
userId: authorId,
21+
priority: 'high',
22+
},
23+
{
24+
id: '10000000-0000-0000-0000-00000000000b',
25+
title: 'Sketch',
26+
userId: authorId,
27+
priority: 'low',
28+
},
29+
{
30+
id: '10000000-0000-0000-0000-00000000000c',
31+
title: 'Polish',
32+
userId: authorId,
33+
priority: 'urgent',
34+
},
35+
{ id: '10000000-0000-0000-0000-00000000000d', title: 'Draft', userId: authorId, priority: 'low' },
36+
] as const;
37+
38+
async function openRuntime(
39+
connectionString: string,
40+
): Promise<{ runtime: Runtime; close: () => Promise<void> }> {
41+
const pool = new Pool({ connectionString });
42+
const stackInstance = instantiateExecutionStack(stack);
43+
const driver = stackInstance.driver;
44+
if (!driver) {
45+
throw new Error('Driver descriptor missing from execution stack');
46+
}
47+
try {
48+
await driver.connect({ kind: 'pgPool', pool });
49+
} catch (error) {
50+
await pool.end();
51+
throw error;
52+
}
53+
const runtime = createRuntime({ stackInstance, context, driver });
54+
return { runtime, close: () => pool.end() };
55+
}
56+
57+
describe('TS-authored enum on the demo contract (Post.priority)', () => {
58+
it(
59+
'db.enums exposes the declaration-ordered runtime surface',
60+
async () => {
61+
await withDevDatabase(async ({ connectionString }) => {
62+
await initTestDatabase({ connection: connectionString, contract });
63+
const { close } = await openRuntime(connectionString);
64+
try {
65+
const priority = getPriorityEnum();
66+
expect(priority.values).toEqual(['low', 'high', 'urgent']);
67+
expect(priority.names).toEqual(['Low', 'High', 'Urgent']);
68+
expect(priority.members.Urgent).toBe('urgent');
69+
expect(priority.has('high')).toBe(true);
70+
const notAMember = 'nope' as 'low' | 'high' | 'urgent';
71+
expect(priority.has(notAMember)).toBe(false);
72+
expect(priority.ordinalOf('urgent')).toBe(2);
73+
} finally {
74+
await close();
75+
}
76+
}, {});
77+
},
78+
timeouts.spinUpPpgDev,
79+
);
80+
81+
it(
82+
'reading Post.priority narrows to the value union and sorts by declaration order',
83+
async () => {
84+
await withDevDatabase(async ({ connectionString }) => {
85+
await initTestDatabase({ connection: connectionString, contract });
86+
const { runtime, close } = await openRuntime(connectionString);
87+
try {
88+
await runtime.execute(
89+
sql.user.insert([{ id: authorId, email: 'author@example.com', kind: 'user' }]).build(),
90+
);
91+
await runtime.execute(sql.post.insert([...seed]).build());
92+
93+
const ordered = await getPostsByPriority(runtime);
94+
95+
// The read narrows to the enum's own value union, not string. The
96+
// expected type is taken from the `db.enums` surface rather than
97+
// re-typed by hand, and asserted against the inferred row type with
98+
// no annotation — so a widening to `string` fails here.
99+
type PriorityValue = ReturnType<typeof getPriorityEnum>['values'][number];
100+
const priorities = ordered.map((row) => row.priority);
101+
expectTypeOf(priorities).toEqualTypeOf<PriorityValue[]>();
102+
103+
// Declaration order is low -> high -> urgent; lexical would be
104+
// high, low, low, urgent.
105+
expect(priorities).toEqual(['low', 'low', 'high', 'urgent']);
106+
expect(ordered.map((row) => row.id)).toEqual([
107+
'10000000-0000-0000-0000-00000000000b',
108+
'10000000-0000-0000-0000-00000000000d',
109+
'10000000-0000-0000-0000-00000000000a',
110+
'10000000-0000-0000-0000-00000000000c',
111+
]);
112+
} finally {
113+
await close();
114+
}
115+
}, {});
116+
},
117+
timeouts.spinUpPpgDev,
118+
);
119+
120+
it(
121+
'the enum CHECK constraint rejects out-of-union values written at runtime',
122+
async () => {
123+
await withDevDatabase(async ({ connectionString }) => {
124+
await initTestDatabase({ connection: connectionString, contract });
125+
const { runtime, close } = await openRuntime(connectionString);
126+
try {
127+
await runtime.execute(
128+
sql.user.insert([{ id: authorId, email: 'author@example.com', kind: 'user' }]).build(),
129+
);
130+
await expect(
131+
runtime.execute(
132+
sql.post
133+
.insert([
134+
{
135+
id: '10000000-0000-0000-0000-0000000000ff',
136+
title: 'Bad',
137+
userId: authorId,
138+
priority: 'nope' as 'low',
139+
},
140+
])
141+
.build(),
142+
),
143+
).rejects.toThrow();
144+
} finally {
145+
await close();
146+
}
147+
}, {});
148+
},
149+
timeouts.spinUpPpgDev,
150+
);
151+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { ResultType } from '@prisma-next/framework-components/runtime';
2+
import { expectTypeOf, test } from 'vitest';
3+
import { type enums, sql } from '../src/prisma-no-emit/context';
4+
5+
type Priority = 'low' | 'high' | 'urgent';
6+
7+
test('reading Post.priority yields the value union, not string', () => {
8+
const plan = sql.post.select('id', 'priority').build();
9+
type Row = ResultType<typeof plan>;
10+
// The non-enum id stays the codec output (string), unaffected by narrowing.
11+
expectTypeOf<Row['id']>().toEqualTypeOf<string>();
12+
// The non-null enum column narrows to its value union — no spurious `| null`.
13+
expectTypeOf<Row['priority']>().toEqualTypeOf<Priority>();
14+
expectTypeOf<Row['priority']>().not.toEqualTypeOf<string>();
15+
});
16+
17+
test('writing Post.priority only accepts the value union', () => {
18+
sql.post.insert([{ id: 'a', title: 'ok', userId: 'u', priority: 'high' }]).build();
19+
20+
sql.post.insert([
21+
// @ts-expect-error 'nope' is not a Priority member value.
22+
{ id: 'b', title: 'bad', userId: 'u', priority: 'nope' },
23+
]);
24+
});
25+
26+
test('db.enums value tuple keeps its literal declaration order', () => {
27+
type Values = (typeof enums)['public']['Priority']['values'];
28+
expectTypeOf<Values>().toEqualTypeOf<readonly ['low', 'high', 'urgent']>();
29+
});

packages/1-framework/0-foundation/contract/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"./apply-specifier-default-control-policy": "./dist/apply-specifier-default-control-policy.mjs",
4545
"./contract-validation-error": "./dist/contract-validation-error.mjs",
4646
"./default-namespace": "./dist/default-namespace.mjs",
47+
"./enum-accessor": "./dist/enum-accessor.mjs",
4748
"./hashing": "./dist/hashing.mjs",
4849
"./hashing-utils": "./dist/hashing-utils.mjs",
4950
"./resolve-domain-model": "./dist/resolve-domain-model.mjs",

packages/1-framework/0-foundation/contract/src/domain-types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { CrossReference } from './cross-reference';
2+
import type { JsonValue } from './types';
23
import type { ValueSetRef } from './value-set-ref';
34

45
export type ScalarFieldType = {
@@ -34,7 +35,7 @@ export type ContractField = {
3435
*/
3536
export type ContractEnum = {
3637
readonly codecId: string;
37-
readonly members: readonly { readonly name: string; readonly value: string }[];
38+
readonly members: readonly { readonly name: string; readonly value: JsonValue }[];
3839
};
3940

4041
export type ContractRelationOn = {

0 commit comments

Comments
 (0)