Skip to content
This repository was archived by the owner on Aug 25, 2026. It is now read-only.

Commit 64dbad6

Browse files
test(native-enums): prove managed enum add-value CRUD against a real Postgres server
The existing add-value coverage runs on PGlite and only checks catalogs. This adds an integration test against a genuine PostgreSQL server: it applies `ALTER TYPE … ADD VALUE`, then in separate committed statements INSERTs a row using the new value, SELECTs it back, UPDATEs another row to it, and DELETEs — proving the appended value is usable for CRUD, which PGlite's single-connection model cannot fully stand in for. Isolated in a throwaway database created and dropped on a maintenance connection, so it never touches existing data. Availability-gated (`describe.runIf`) so a bare checkout skips instead of failing; it connects to `localhost:5432`, matching the `postgres:15` service the CI test jobs already provision (previously unused), so it runs for real in CI with no new workflow config. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
1 parent 85ddade commit 64dbad6

3 files changed

Lines changed: 332 additions & 2 deletions

File tree

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
/**
2+
* Managed native-enum add-value migration against a REAL PostgreSQL server
3+
* (not PGlite): plans and applies `ALTER TYPE … ADD VALUE`, then proves the
4+
* newly-added value is usable for CRUD in separate, subsequent statements —
5+
* the whole point of committing the ALTER on a real server (PGlite tests
6+
* cover planning/apply-ordering but can't prove this cross-transaction
7+
* usability).
8+
*
9+
* Isolated in a throwaway database (`prisma_next_native_enum_add_value_realdb`)
10+
* dropped and recreated on a maintenance connection; skips (does not fail)
11+
* when no real Postgres is reachable.
12+
*/
13+
import type { Contract, ControlPolicy } from '@prisma-next/contract/types';
14+
import { INIT_ADDITIVE_POLICY } from '@prisma-next/family-sql/control';
15+
import {
16+
APP_SPACE_ID,
17+
assembleAuthoringContributions,
18+
type MigrationOperationPolicy,
19+
} from '@prisma-next/framework-components/control';
20+
import { buildSymbolTable } from '@prisma-next/psl-parser';
21+
import { parse } from '@prisma-next/psl-parser/syntax';
22+
import type { SqlStorage } from '@prisma-next/sql-contract/types';
23+
import { interpretPslDocumentToSqlContract } from '@prisma-next/sql-contract-psl';
24+
import type { SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types';
25+
import {
26+
PostgresDatabaseSchemaNode,
27+
postgresCreateNamespace,
28+
} from '@prisma-next/target-postgres/types';
29+
import { ifDefined } from '@prisma-next/utils/defined';
30+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
31+
import { createPostgresBuiltinCodecLookup } from '../../src/core/codec-lookup';
32+
import { createPostgresScalarTypeDescriptors } from '../../src/core/control-mutation-defaults';
33+
import {
34+
controlAdapter,
35+
createDriver,
36+
emptySchema,
37+
familyInstance,
38+
formatRunnerFailure,
39+
frameworkComponents,
40+
type PostgresControlDriver,
41+
postgresTargetDescriptor,
42+
synthEdges,
43+
testTimeout,
44+
} from './fixtures/runner-fixtures';
45+
46+
// ============================================================================
47+
// PSL sources
48+
// ============================================================================
49+
50+
const PSL_ENUM_TWO_MEMBERS = `
51+
namespace public {
52+
native_enum OrderStatus {
53+
draft = "draft"
54+
review = "review"
55+
@@map("order_status")
56+
}
57+
58+
model orders {
59+
id Int @id
60+
status pg.enum(OrderStatus)
61+
}
62+
}
63+
`;
64+
65+
const PSL_WITH_ENUM = `
66+
namespace public {
67+
native_enum OrderStatus {
68+
draft = "draft"
69+
review = "review"
70+
done = "done"
71+
@@map("order_status")
72+
}
73+
74+
model orders {
75+
id Int @id
76+
status pg.enum(OrderStatus)
77+
}
78+
}
79+
`;
80+
81+
// ============================================================================
82+
// PSL → contract helpers (mirrors native-enum-lifecycle-e2e.integration.test.ts)
83+
// ============================================================================
84+
85+
function buildScalarTypeDescriptors(): ReadonlyMap<
86+
string,
87+
{ codecId: string; nativeType: string }
88+
> {
89+
const codecIdMap = createPostgresScalarTypeDescriptors();
90+
const codecLookup = createPostgresBuiltinCodecLookup();
91+
const result = new Map<string, { codecId: string; nativeType: string }>();
92+
for (const [typeName, codecId] of codecIdMap) {
93+
const nativeType = codecLookup.targetTypesFor(codecId)?.[0];
94+
if (nativeType !== undefined) {
95+
result.set(typeName, { codecId, nativeType });
96+
}
97+
}
98+
return result;
99+
}
100+
101+
function buildContractFromPsl(psl: string, control: ControlPolicy): Contract<SqlStorage> {
102+
const assembled = assembleAuthoringContributions([postgresTargetDescriptor]);
103+
const scalarTypeDescriptors = buildScalarTypeDescriptors();
104+
105+
const { document, sourceFile } = parse(psl);
106+
const { table: symbolTable } = buildSymbolTable({
107+
document,
108+
sourceFile,
109+
scalarTypes: [...scalarTypeDescriptors.keys()],
110+
pslBlockDescriptors: assembled.pslBlockDescriptors,
111+
});
112+
113+
const result = interpretPslDocumentToSqlContract({
114+
symbolTable,
115+
sourceFile,
116+
sourceId: 'schema.prisma',
117+
target: {
118+
kind: 'target' as const,
119+
familyId: 'sql' as const,
120+
targetId: 'postgres' as const,
121+
id: 'postgres',
122+
version: postgresTargetDescriptor.version,
123+
capabilities: {},
124+
defaultNamespaceId: 'public',
125+
...ifDefined('authoring', postgresTargetDescriptor.authoring),
126+
},
127+
scalarTypeDescriptors,
128+
authoringContributions: assembled,
129+
composedExtensionContracts: new Map(),
130+
createNamespace: postgresCreateNamespace,
131+
codecLookup: createPostgresBuiltinCodecLookup(),
132+
capabilities: { sql: { scalarList: true } },
133+
});
134+
135+
if (!result.ok) throw new Error(`PSL interpretation failed: ${JSON.stringify(result)}`);
136+
return { ...(result.value as Contract<SqlStorage>), defaultControlPolicy: control };
137+
}
138+
139+
async function planContract(
140+
contract: Contract<SqlStorage>,
141+
schema: SqlSchemaIRNode,
142+
policy: MigrationOperationPolicy,
143+
) {
144+
const planner = postgresTargetDescriptor.createPlanner(controlAdapter);
145+
const planResult = planner.plan({
146+
contract,
147+
schema,
148+
policy,
149+
fromContract: null,
150+
frameworkComponents,
151+
spaceId: APP_SPACE_ID,
152+
});
153+
if (planResult.kind !== 'success')
154+
throw new Error(`Planner failed: ${JSON.stringify(planResult)}`);
155+
return planResult.plan;
156+
}
157+
158+
async function applyPlan(
159+
driver: PostgresControlDriver,
160+
plan: Awaited<ReturnType<typeof planContract>>,
161+
contract: Contract<SqlStorage>,
162+
policy: MigrationOperationPolicy,
163+
): Promise<void> {
164+
const runner = postgresTargetDescriptor.createRunner(familyInstance);
165+
const executeResult = await runner.execute({
166+
driver,
167+
perSpaceOptions: [
168+
{
169+
space: plan.spaceId ?? APP_SPACE_ID,
170+
plan,
171+
migrationEdges: synthEdges(plan),
172+
driver,
173+
destinationContract: contract,
174+
policy,
175+
frameworkComponents,
176+
},
177+
],
178+
});
179+
if (!executeResult.ok)
180+
throw new Error(`Runner failed:\n${formatRunnerFailure(executeResult.failure)}`);
181+
}
182+
183+
async function opIds(plan: Awaited<ReturnType<typeof planContract>>): Promise<readonly string[]> {
184+
const ops = await Promise.all(plan.operations);
185+
return ops.map((op) => op.id);
186+
}
187+
188+
/** The ordered member list of a native enum type in a namespace of an introspected/live schema tree, or `undefined` when the namespace or type is absent. */
189+
function nativeEnumMembers(
190+
schema: SqlSchemaIRNode,
191+
namespaceId: string,
192+
typeName: string,
193+
): readonly string[] | undefined {
194+
PostgresDatabaseSchemaNode.assert(schema);
195+
return schema.namespaces[namespaceId]?.nativeEnums.find((e) => e.typeName === typeName)?.members;
196+
}
197+
198+
// ============================================================================
199+
// Real-DB connection + isolation
200+
// ============================================================================
201+
202+
const MAINTENANCE_URL =
203+
process.env['DATABASE_URL'] ?? 'postgres://postgres:postgres@localhost:5432/postgres';
204+
const TEST_DB = 'prisma_next_native_enum_add_value_realdb';
205+
206+
function testDatabaseUrl(): string {
207+
const u = new URL(MAINTENANCE_URL);
208+
u.pathname = '/' + TEST_DB;
209+
return u.toString();
210+
}
211+
212+
async function isRealPostgresAvailable(): Promise<boolean> {
213+
try {
214+
const d = await createDriver(MAINTENANCE_URL);
215+
await d.query('select 1');
216+
await d.close();
217+
return true;
218+
} catch {
219+
return false;
220+
}
221+
}
222+
223+
async function dropTestDatabaseViaMaintenance(): Promise<void> {
224+
const maintenance = await createDriver(MAINTENANCE_URL);
225+
await maintenance.query(`DROP DATABASE IF EXISTS ${TEST_DB} WITH (FORCE)`);
226+
await maintenance.close();
227+
}
228+
229+
// ============================================================================
230+
// Tests
231+
// ============================================================================
232+
233+
describe.runIf(await isRealPostgresAvailable())(
234+
'managed native-enum add-value — real Postgres CRUD (R8)',
235+
() => {
236+
let driver: PostgresControlDriver | undefined;
237+
238+
beforeAll(async () => {
239+
const maintenance = await createDriver(MAINTENANCE_URL);
240+
await maintenance.query(`DROP DATABASE IF EXISTS ${TEST_DB} WITH (FORCE)`);
241+
await maintenance.query(`CREATE DATABASE ${TEST_DB}`);
242+
await maintenance.close();
243+
244+
driver = await createDriver(testDatabaseUrl());
245+
}, testTimeout);
246+
247+
afterAll(async () => {
248+
await driver?.close();
249+
await dropTestDatabaseViaMaintenance();
250+
}, testTimeout);
251+
252+
it(
253+
'appends a member via ALTER TYPE ADD VALUE and the new value is usable for CRUD on a real server',
254+
async () => {
255+
// 1. Plan + apply the 2-member baseline from empty.
256+
const baseContract = buildContractFromPsl(PSL_ENUM_TWO_MEMBERS, 'managed');
257+
const basePlan = await planContract(baseContract, emptySchema, INIT_ADDITIVE_POLICY);
258+
await applyPlan(driver!, basePlan, baseContract, INIT_ADDITIVE_POLICY);
259+
260+
// 2. Introspect, plan the append to 3 members, apply it.
261+
const introspectedBase = await familyInstance.introspect({
262+
driver: driver!,
263+
contract: baseContract,
264+
});
265+
const appendedContract = buildContractFromPsl(PSL_WITH_ENUM, 'managed');
266+
const appendPlan = await planContract(
267+
appendedContract,
268+
introspectedBase,
269+
INIT_ADDITIVE_POLICY,
270+
);
271+
const ids = await opIds(appendPlan);
272+
expect(ids).toEqual(['addNativeEnumValue.order_status.done']);
273+
await applyPlan(driver!, appendPlan, appendedContract, INIT_ADDITIVE_POLICY);
274+
275+
// 3. The new member is live.
276+
const introspectedAfter = await familyInstance.introspect({
277+
driver: driver!,
278+
contract: appendedContract,
279+
});
280+
expect(nativeEnumMembers(introspectedAfter, 'public', 'order_status')).toEqual([
281+
'draft',
282+
'review',
283+
'done',
284+
]);
285+
286+
// 4. CRUD using the newly-added 'done' value, in separate statements
287+
// (the whole point of a real, committed ALTER TYPE ADD VALUE).
288+
await driver!.query('INSERT INTO "public"."orders" (id, status) VALUES ($1, $2)', [
289+
1,
290+
'done',
291+
]);
292+
const createdRow = await driver!.query<{ status: string }>(
293+
'SELECT status FROM "public"."orders" WHERE id = $1',
294+
[1],
295+
);
296+
expect(createdRow.rows[0]?.status).toBe('done');
297+
298+
await driver!.query('INSERT INTO "public"."orders" (id, status) VALUES ($1, $2)', [
299+
2,
300+
'draft',
301+
]);
302+
await driver!.query('UPDATE "public"."orders" SET status = $1 WHERE id = $2', ['done', 2]);
303+
const updatedRow = await driver!.query<{ status: string }>(
304+
'SELECT status FROM "public"."orders" WHERE id = $1',
305+
[2],
306+
);
307+
expect(updatedRow.rows[0]?.status).toBe('done');
308+
309+
await driver!.query('DELETE FROM "public"."orders" WHERE id = $1', [2]);
310+
const remaining = await driver!.query<{ count: string }>(
311+
'SELECT count(*)::text AS count FROM "public"."orders" WHERE id = $1',
312+
[2],
313+
);
314+
expect(remaining.rows[0]?.count).toBe('0');
315+
316+
// 5. Verify is clean against the appended contract.
317+
const verify = familyInstance.verifySchema({
318+
contract: appendedContract,
319+
schema: introspectedAfter,
320+
strict: true,
321+
frameworkComponents,
322+
});
323+
expect(verify.ok).toBe(true);
324+
},
325+
testTimeout,
326+
);
327+
},
328+
);

projects/native-postgres-enums/slices/managed-native-enum-add-value/code-review.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,6 @@
2727
- R9 rename / removal / reorder → each `planDirect` returns `failure` carrying the exact operator-worded refusal (verbatim `orderStatusRefusalMessage`), zero ops, and the live member list is unchanged after the attempt (DB untouched).
2828
- R5 external enum carrying a live-appended fourth value → zero `addNativeEnumValue` / `createNativeEnumType` / `dropNativeEnumType` ops under the external grade; the extra live value is left in place.
2929

30+
**Verified live (real PostgreSQL), in `native-enum-add-value.real-postgres.integration.test.ts`:** against a genuine Postgres server (throwaway database created/dropped on a maintenance connection, availability-gated so a bare checkout skips, and matching the `postgres:15` service CI already provisions on `localhost:5432`), the append applies and the new value is usable for CRUD in statements after the migration commits — INSERT `'done'` → SELECT; INSERT `'draft'` → UPDATE to `'done'` → SELECT; DELETE — plus strict verify clean. This is the cross-transaction usability PGlite's single-connection model can't fully stand in for.
31+
3032
**Enums-only namespace pulled from scope (Option A):** whether a model-less, enums-only namespace reaches verify/plan is an authoring-surface limitation (`buildSqlContractFromDefinition` derives a namespace's existence from its models), not a planner one — moved to the generic contract-builder follow-up slice. D2's `pruneTableLessNamespaces` widening and its enums-only tests were reverted; Slice A's prune stands unchanged. Reviewer focus for this dispatch is carried by D1 above (the `additive` op-class grading and the `qualifiedNativeEnumTypeName` unbound path).

projects/native-postgres-enums/slices/managed-native-enum-add-value/spec.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ One reviewer sitting: a planner-lowering change confined to one function's tail,
4646

4747
## Scope
4848

49-
**In:** the suffix-append classification + `AddNativeEnumValueCall` lowering; the plain-language refusal diagnostic with the `pris.ly` link; the `docs/reference/postgres-native-enums.md` page (content from the project explainer, `projects/` copy becomes a pointer); the op class with prechecks/postchecks + caveat-bearing description; the hand-authored `addNativeEnumValue`; unit + planner tests; a live PGlite integration proof (single + multi append, all three refusal classes, external append suppressed).
49+
**In:** the suffix-append classification + `AddNativeEnumValueCall` lowering; the plain-language refusal diagnostic with the `pris.ly` link; the `docs/reference/postgres-native-enums.md` page (content from the project explainer, `projects/` copy becomes a pointer); the op class with prechecks/postchecks + caveat-bearing description; the hand-authored `addNativeEnumValue`; unit + planner tests; a live PGlite integration proof (single + multi append, all three refusal classes, external append suppressed); a **real-PostgreSQL** integration proof that the appended value round-trips CRUD (throwaway-database isolated, availability-gated, wired to the Postgres service CI already provisions).
5050

5151
**Deliberately out:**
5252

@@ -67,7 +67,7 @@ One reviewer sitting: a planner-lowering change confined to one function's tail,
6767

6868
## Slice-specific done conditions
6969

70-
R8 and R9 proven against a live database (PGlite): the append path applies and round-trips verify; each refusal class (rename, removal, reorder) yields the diagnostic and zero ops. Plan output shows the caveat on the `ADD VALUE` op description. (CI-green, reviewer-accept, project-DoD floor inherited.)
70+
R8 and R9 proven against a live database: the append path applies and round-trips verify, and each refusal class (rename, removal, reorder) yields the diagnostic and zero ops (PGlite). Additionally, against a **real PostgreSQL server** (throwaway database, skipped when none is reachable), the appended value is usable for CRUD — INSERT / SELECT / UPDATE / DELETE in statements after the migration commits — which PGlite's single-connection model cannot fully stand in for. Plan output shows the caveat on the `ADD VALUE` op description. (CI-green, reviewer-accept, project-DoD floor inherited.)
7171

7272
## Open questions
7373

0 commit comments

Comments
 (0)