1import * as Neon from "@/Neon";
2import * as Prisma from "@/Prisma";
3import * as PrismaPostgres from "@/Prisma/ORM/Postgres.ts";
4import * as Test from "@/Test/Alchemy";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Path from "effect/Path";
9import * as Redacted from "effect/Redacted";
10import * as Result from "effect/Result";
11import * as Stream from "effect/Stream";
12import { contract } from "./fixtures/client/contract.ts";
13import { makeDatabase } from "./fixtures/psl/generated/client.ts";
14import { makeDatabase as makeVariantDatabase } from "./fixtures/variants/generated/client.ts";
15import { schemas } from "./fixtures/psl/generated/schemas.ts";
16import * as Schema from "effect/Schema";
17
18const { test } = Test.make({
19 providers: Layer.mergeAll(Prisma.providers(), Neon.providers()),
20});
21
22const HOOK_TIMEOUT = 120_000;
23
24test.provider(
25 "PSL-generated client: CRUD, relations, schemas, and rollback",
26 (stack) =>
27 Effect.gen(function* () {
28 yield* stack.destroy();
29 const path = yield* Path.Path;
30 const config = yield* path.fromFileUrl(
31 new URL("./fixtures/psl/prisma.config.ts", import.meta.url),
32 );
33 const { branch } = yield* stack.deploy(
34 Effect.gen(function* () {
35 const contract = yield* Prisma.Contract("psl-contract", { config });
36 const project = yield* Neon.Project("PrismaPslProject");
37 const branch = yield* Neon.Branch("PrismaPslBranch", { project });
38 yield* Prisma.Migrate("psl-migrate", {
39 contract,
40 url: branch.connectionUri,
41 });
42 return { branch };
43 }),
44 );
45 const db = yield* makeDatabase(
46 Effect.succeed(Redacted.make(branch.connectionUri)),
47 );
48 yield* Effect.scoped(
49 Effect.gen(function* () {
50 const user = yield* db.orm.public.User.create({
51 email: "psl@example.com",
52 name: null,
53 });
54 const validated = yield* Schema.decodeUnknownEffect(
55 schemas.public.User,
56 )(user);
57 expect(validated.id).toBe(user.id);
58 yield* db.orm.public.Post.create({
59 title: "PSL post",
60 authorId: user.id,
61 });
62 const loaded = yield* db.orm.public.User.where({ id: user.id })
63 .include("posts")
64 .first();
65 expect(loaded?.posts.map((post) => post.title)).toEqual(["PSL post"]);
66 const rollback = yield* db
67 .transaction((tx) =>
68 Effect.gen(function* () {
69 yield* tx.orm.public.User.where({ id: user.id }).update({
70 name: "rolled back",
71 });
72 return yield* tx.rollback();
73 }),
74 )
75 .pipe(Effect.result);
76 expect(Result.isFailure(rollback)).toBe(true);
77 expect(
78 (yield* db.orm.public.User.where({ id: user.id }).first())?.name,
79 ).toBeNull();
80 yield* db.orm.public.Post.where({ authorId: user.id }).delete();
81 yield* db.orm.public.User.where({ id: user.id }).delete();
82 expect(yield* db.orm.public.User.all()).toEqual([]);
83 }),
84 );
85 yield* stack.destroy();
86 }),
87 {
88 tags: [
89 "provider:neon",
90 "provider:neon:branch",
91 "provider:neon:project",
92 "provider:prisma",
93 "provider:prisma:orm",
94 "live",
95 ],
96 timeout: HOOK_TIMEOUT,
97 },
98);
99
100test.provider(
101 "polymorphic collections: single-table and multi-table variants",
102 (stack) =>
103 Effect.gen(function* () {
104 yield* stack.destroy();
105 const path = yield* Path.Path;
106 const config = yield* path.fromFileUrl(
107 new URL("./fixtures/variants/prisma.config.ts", import.meta.url),
108 );
109 const { branch } = yield* stack.deploy(
110 Effect.gen(function* () {
111 const contract = yield* Prisma.Contract("variant-contract", {
112 config,
113 });
114 const project = yield* Neon.Project("PrismaVariantProject");
115 const branch = yield* Neon.Branch("PrismaVariantBranch", { project });
116 yield* Prisma.Migrate("variant-migrate", {
117 contract,
118 url: branch.connectionUri,
119 });
120 return { branch };
121 }),
122 );
123 const db = yield* makeVariantDatabase(
124 Effect.succeed(Redacted.make(branch.connectionUri)),
125 );
126 yield* Effect.scoped(
127 Effect.gen(function* () {
128 const assignee = yield* db.orm.public.Assignee.create({
129 name: "Sam",
130 });
131 const bug = yield* db.orm.public.Task.variant("Bug").create({
132 title: "Crash",
133 severity: "critical",
134 assigneeId: assignee.id,
135 });
136 const feature = yield* db.orm.public.Task.variant("Feature").create({
137 title: "Search",
138 priority: 3,
139 });
140 expect(bug).toMatchObject({ type: "bug", severity: "critical" });
141 expect(feature).toMatchObject({ type: "feature", priority: 3 });
142 const all = yield* db.orm.public.Task.orderBy((task) =>
143 task.id.asc(),
144 ).all();
145 expect(all.map((task) => task.type)).toEqual(["bug", "feature"]);
146 const narrowed = yield* db.orm.public.Task.variant("Feature")
147 .where((task) => task.priority.gte(3))
148 .orderBy((task) => task.priority.desc())
149 .first();
150 expect(narrowed).toMatchObject({ id: feature.id, priority: 3 });
151 const included = yield* db.orm.public.Task.variant("Bug")
152 .include("assignee")
153 .first();
154 expect(included?.assignee).toEqual(assignee);
155 const selected = yield* db.orm.public.Task.variant("Bug")
156 .include("assignee", (person) => person.select("name"))
157 .select("id", "title")
158 .first();
159 expect(selected).toEqual({
160 id: bug.id,
161 title: "Crash",
162 assignee: { name: "Sam" },
163 });
164 const rolledBack = yield* db
165 .transaction((tx) =>
166 Effect.gen(function* () {
167 yield* tx.orm.public.Task.variant("Feature").create({
168 title: "Undo",
169 priority: 5,
170 });
171 return yield* tx.rollback();
172 }),
173 )
174 .pipe(Effect.result);
175 expect(Result.isFailure(rolledBack)).toBe(true);
176 expect(
177 yield* db.orm.public.Task.variant("Feature").all(),
178 ).toHaveLength(1);
179 expect(
180 yield* db.orm.public.Task.variant("Feature").deleteAndCount(),
181 ).toBe(1);
182 expect(
183 yield* db.orm.public.Task.variant("Bug").deleteAndCount(),
184 ).toBe(1);
185 expect(yield* db.orm.public.Task.all()).toEqual([]);
186 }),
187 );
188 yield* stack.destroy();
189 }),
190 {
191 tags: [
192 "provider:neon",
193 "provider:neon:branch",
194 "provider:neon:project",
195 "provider:prisma",
196 "provider:prisma:orm",
197 "live",
198 ],
199 timeout: HOOK_TIMEOUT,
200 },
201);
202
203const fixtureConfig = Effect.gen(function* () {
204 const path = yield* Path.Path;
205 const self = yield* path.fromFileUrl(new URL(import.meta.url));
206 return path.join(
207 path.dirname(self),
208 "fixtures",
209 "client",
210 "prisma.config.ts",
211 );
212});
213
214/** Deploy the fixture contract onto a fresh Neon branch and hand back a db. */
215const deployDatabase = (stack: {
216 deploy: <A, E, R>(e: Effect.Effect<A, E, R>) => any;
217}) =>
218 Effect.gen(function* () {
219 const configPath = yield* fixtureConfig;
220 const { branch } = yield* stack.deploy(
221 Effect.gen(function* () {
222 const contract = yield* Prisma.Contract("client-contract", {
223 config: configPath,
224 });
225 const project = yield* Neon.Project("PrismaClientProject");
226 const branch = yield* Neon.Branch("PrismaClientBranch", { project });
227 yield* Prisma.Migrate("client-migrate", {
228 url: branch.connectionUri,
229 contract,
230 });
231 return { branch };
232 }),
233 );
234 return yield* PrismaPostgres.Postgres(
235 Effect.succeed(Redacted.make(branch.connectionUri as string)),
236 { contract },
237 );
238 });
239
240test.provider(
241 "orm lane: typed CRUD, include, select, and where gating",
242 (stack) =>
243 Effect.gen(function* () {
244 yield* stack.destroy();
245 const db = yield* deployDatabase(stack);
246
247 yield* Effect.scoped(
248 Effect.gen(function* () {
249 // create — input + row are contract-typed
250 const alice = yield* db.orm.public.User.create({
251 email: "alice@example.com",
252 name: "Alice",
253 });
254 expect(alice.id).toBeGreaterThan(0);
255 expect(alice.name).toEqual("Alice");
256
257 yield* db.orm.public.Post.create({
258 title: "hello",
259 authorId: alice.id,
260 });
261
262 // include — Row gains a typed relation field
263 const withPosts = yield* db.orm.public.User.where({ id: alice.id })
264 .include("posts")
265 .first();
266 expect(withPosts?.posts).toHaveLength(1);
267 expect(withPosts?.posts[0]?.title).toEqual("hello");
268
269 // Shorthand filters keep their model field and value types.
270 // @ts-expect-error unknown fields are not valid filters
271 db.orm.public.User.where({ missing: "value" });
272 // @ts-expect-error User.id is a number
273 db.orm.public.User.where({ id: "not-a-number" });
274
275 const bulk = yield* db.orm.public.Post.createAll([
276 { title: "bulk-one", authorId: alice.id },
277 { title: "bulk-two", authorId: alice.id },
278 ]);
279 expect(bulk.map((post) => post.title)).toEqual([
280 "bulk-one",
281 "bulk-two",
282 ]);
283 const count = yield* db.orm.public.Post.createAndCount([
284 { title: "bulk-count", authorId: alice.id },
285 ]);
286 expect(count).toBe(1);
287 const upserted = yield* db.orm.public.User.upsert({
288 create: { email: "alice@example.com", name: "Alice" },
289 update: { name: "Alice" },
290 conflictOn: { email: "alice@example.com" },
291 });
292 expect(upserted.id).toBe(alice.id);
293
294 db.orm.public.User.createAll([
295 // @ts-expect-error bulk creates accept scalar fields only
296 { email: "bulk@example.com", posts: () => undefined },
297 ]);
298 db.orm.public.User.createAndCount([
299 // @ts-expect-error bulk creates accept scalar fields only
300 { email: "bulk@example.com", posts: () => undefined },
301 ]);
302 db.orm.public.User.upsert({
303 // @ts-expect-error upsert creates accept scalar fields only
304 create: { email: "bulk@example.com", posts: () => undefined },
305 update: {},
306 });
307
308 // where callback form
309 const byEmail = yield* db.orm.public.User.where((u) =>
310 u.email.eq("alice@example.com"),
311 ).first();
312 expect(byEmail?.id).toEqual(alice.id);
313
314 // select — Row narrows to the projection
315 const projected = yield* db.orm.public.User.select(
316 "id",
317 "email",
318 ).all();
319 expect(projected[0]?.email).toEqual("alice@example.com");
320 // @ts-expect-error `name` was not selected — narrowing is real
321 projected[0]?.name;
322
323 // update requires a prior where (compile-time gate) and works
324 const renamed = yield* db.orm.public.User.where({
325 id: alice.id,
326 }).update({ name: "Alice II" });
327 expect(renamed?.name).toEqual("Alice II");
328
329 // @ts-expect-error update without where() is a type error
330 db.orm.public.User.update({ name: "nope" });
331
332 // laziness: one effect, two evaluations → two rows
333 const insert = db.orm.public.Post.create({
334 title: "repeat",
335 authorId: alice.id,
336 });
337 yield* insert;
338 yield* insert;
339 const repeats = yield* db.orm.public.Post.where({
340 title: "repeat",
341 }).all();
342 expect(repeats).toHaveLength(2);
343
344 const refined = yield* db.orm.public.User.select("id")
345 .include("posts", (posts) =>
346 posts
347 .select("title")
348 .orderBy((post) => post.id.desc())
349 .limit(2),
350 )
351 .first();
352 expect(refined?.posts).toEqual([
353 { title: "repeat" },
354 { title: "repeat" },
355 ]);
356 const combined = yield* db.orm.public.User.select("id")
357 .include("posts", (posts) =>
358 posts.combine({
359 recent: posts
360 .select("title")
361 .orderBy((post) => post.id.desc())
362 .limit(1),
363 total: posts.count(),
364 }),
365 )
366 .first();
367 expect(combined?.posts).toEqual({
368 recent: [{ title: "repeat" }],
369 total: 6,
370 });
371 const grouped = yield* db.orm.public.Post.groupBy("authorId")
372 .orderBy((post) => post.authorId.asc())
373 .limit(1)
374 .aggregate((aggregate) => ({ total: aggregate.count() }));
375 expect(grouped).toEqual([{ authorId: alice.id, total: 6 }]);
376 const streamedRows = yield* db.orm.public.Post.select("title")
377 .orderBy((post) => post.id.asc())
378 .all()
379 .stream.pipe(Stream.take(2), Stream.runCollect);
380 expect([...streamedRows]).toEqual([
381 { title: "hello" },
382 { title: "bulk-one" },
383 ]);
384 const page = yield* db.orm.public.Post.orderBy((post) =>
385 post.id.asc(),
386 )
387 .cursor({ id: bulk[0]!.id })
388 .limit(1)
389 .all();
390 expect(page.map((post) => post.title)).toEqual(["bulk-two"]);
391 const distinct = yield* db.orm.public.Post.distinct("title")
392 .select("title")
393 .all();
394 expect(distinct).toHaveLength(5);
395 const distinctOn = yield* db.orm.public.Post.orderBy([
396 (post) => post.title.asc(),
397 (post) => post.id.asc(),
398 ])
399 .distinctOn("title")
400 .all();
401 expect(distinctOn).toHaveLength(5);
402 expect(distinctOn.find((post) => post.title === "repeat")?.id).toBe(
403 repeats[0]!.id,
404 );
405 expect(
406 yield* db.orm.public.Post.aggregate((aggregate) => ({
407 total: aggregate.count(),
408 highestId: aggregate.max("id"),
409 })),
410 ).toEqual({ total: 6, highestId: repeats[1]!.id });
411 expect(
412 yield* db.orm.public.Post.groupBy("authorId")
413 .having((aggregate) => aggregate.count().gt(6))
414 .aggregate((aggregate) => ({ total: aggregate.count() })),
415 ).toEqual([]);
416 expect(
417 yield* db.orm.public.Post.groupBy("authorId")
418 .orderBy((post) => post.authorId.asc())
419 .offset(1)
420 .aggregate((aggregate) => ({ total: aggregate.count() })),
421 ).toEqual([]);
422 const changed = yield* db.orm.public.Post.where({
423 title: "repeat",
424 }).updateAll({ title: "updated" });
425 expect(changed.map((post) => post.title)).toEqual([
426 "updated",
427 "updated",
428 ]);
429 expect(
430 yield* db.orm.public.Post.where({
431 title: "updated",
432 }).updateAndCount({ title: "counted" }),
433 ).toBe(2);
434 const removed = yield* db.orm.public.Post.where({ title: "counted" })
435 .deleteAll()
436 .stream.pipe(Stream.runCollect);
437 expect(removed).toHaveLength(2);
438 expect(
439 yield* db.orm.public.Post.where({
440 title: "bulk-count",
441 }).deleteAndCount(),
442 ).toBe(1);
443 expect(
444 yield* db.orm.public.Post.orderBy((post) => post.id.asc())
445 .offset(1)
446 .limit(1)
447 .first(),
448 ).toMatchObject({ title: "bulk-one" });
449
450 // granular constraint tags: unique violation is its own error
451 const dup = yield* db.orm.public.User.create({
452 email: "alice@example.com",
453 }).pipe(
454 Effect.as("created" as const),
455 Effect.catchTag("Prisma.UniqueViolationError", (error) =>
456 Effect.succeed(
457 error.sqlState === "23505"
458 ? ("unique" as const)
459 : ("other" as const),
460 ),
461 ),
462 );
463 expect(dup).toEqual("unique");
464
465 // ...and so is a foreign-key violation
466 const fk = yield* db.orm.public.Post.create({
467 title: "orphan",
468 authorId: 999_999,
469 }).pipe(
470 Effect.as("created" as const),
471 Effect.catchTag("Prisma.ForeignKeyViolationError", () =>
472 Effect.succeed("fk-violation" as const),
473 ),
474 );
475 expect(fk).toEqual("fk-violation");
476
477 // ORM misuse surfaces as the ORM category tag with a typed code.
478 // (`count()` is refinement-only; even prisma's own types reject
479 // this call, so the cast is deliberate — the test pins the
480 // *runtime* classification of the thrown ORM.* error.)
481 const misuse = yield* db
482 .use(
483 (c) =>
484 c.orm.public.User.count() as unknown as PromiseLike<unknown>,
485 )
486 .pipe(
487 Effect.as("ok" as const),
488 Effect.catchTag("Prisma.OrmError", (error) =>
489 Effect.succeed(error.code),
490 ),
491 );
492 expect(misuse).toEqual("ORM.INCLUDE_INVALID");
493
494 // delete gating + row-returning delete
495 const deleted = yield* db.orm.public.User.where({
496 email: "nobody@example.com",
497 }).delete();
498 expect(deleted).toBeNull();
499 }),
500 );
501
502 yield* stack.destroy();
503 }),
504 {
505 tags: [
506 "provider:neon",
507 "provider:neon:branch",
508 "provider:neon:project",
509 "provider:prisma",
510 "provider:prisma:orm",
511 "live",
512 ],
513 timeout: HOOK_TIMEOUT,
514 },
515);
516
517test.provider(
518 "sql lane, streaming, and transactions",
519 (stack) =>
520 Effect.gen(function* () {
521 yield* stack.destroy();
522 const db = yield* deployDatabase(stack);
523
524 const statement = yield* Effect.scoped(
525 Effect.gen(function* () {
526 const bob = yield* db.orm.public.User.create({
527 email: "bob@example.com",
528 name: "Bob",
529 });
530
531 // sql builder lane: pure plan, Effect executor, typed rows
532 const plan = db.sql.public.user.select("id", "email").build();
533 const rows = yield* db.execute(plan);
534 expect(rows.map((r) => r.email)).toContain("bob@example.com");
535
536 // stream lane (fresh execution per run)
537 const streamed = yield* Stream.runCollect(db.stream(plan));
538 expect([...streamed].length).toBe(rows.length);
539
540 const prepared = yield* db.prepare(
541 { email: "pg/text@1" },
542 (sql, params) =>
543 sql.public.user
544 .select("id", "email")
545 .where((fields, fns) => fns.eq(fields.email, params.email))
546 .build(),
547 );
548 const preparedRead = prepared.query({ email: "bob@example.com" });
549 expect(yield* preparedRead).toEqual([
550 { id: bob.id, email: "bob@example.com" },
551 ]);
552 expect(yield* preparedRead).toEqual([
553 { id: bob.id, email: "bob@example.com" },
554 ]);
555 expect([...(yield* Stream.runCollect(preparedRead.stream))]).toEqual([
556 { id: bob.id, email: "bob@example.com" },
557 ]);
558 expect(
559 yield* prepared.query({ email: "missing@example.com" }),
560 ).toEqual([]);
561 const rename = yield* db.prepare(
562 { id: "pg/int4@1", name: "pg/text@1" },
563 (_sql, params) =>
564 db.raw
565 .sql`UPDATE "user" SET name = ${params.name} WHERE id = ${params.id}`
566 .affectedCount()
567 .build(),
568 );
569 expect(
570 yield* rename.execute({ id: bob.id, name: "Prepared Bob" }),
571 ).toEqual({ affectedRows: 1 });
572 expect(
573 (yield* db.orm.public.User.where({ id: bob.id }).first())?.name,
574 ).toBe("Prepared Bob");
575 const unused = yield* db
576 .prepare({ unused: "pg/text@1" }, (sql) =>
577 sql.public.user.select("id").build(),
578 )
579 .pipe(
580 Effect.as("unexpected success"),
581 Effect.catchTag("Prisma.RuntimeError", (error) =>
582 Effect.succeed(error.code),
583 ),
584 );
585 expect(unused).toBe("RUNTIME.PREPARE_UNUSED_PARAM");
586
587 // transaction: commit path
588 const committed = yield* db.transaction((tx) =>
589 Effect.gen(function* () {
590 const post = yield* tx.orm.public.Post.create({
591 title: "tx-post",
592 authorId: bob.id,
593 });
594 const read = yield* tx.orm.public.Post.where({
595 id: post.id,
596 }).first();
597 return read?.title;
598 }),
599 );
600 expect(committed).toEqual("tx-post");
601 const persisted = yield* db.orm.public.Post.where({
602 title: "tx-post",
603 }).all();
604 expect(persisted).toHaveLength(1);
605
606 // transaction: explicit rollback is a typed failure and undoes writes
607 const rolledBack = yield* db
608 .transaction((tx) =>
609 Effect.gen(function* () {
610 yield* tx.orm.public.Post.create({
611 title: "rollback-post",
612 authorId: bob.id,
613 });
614 const update = yield* tx.prepare(
615 { id: "pg/int4@1", name: "pg/text@1" },
616 (_sql, params) =>
617 db.raw
618 .sql`UPDATE "user" SET name = ${params.name} WHERE id = ${params.id}`
619 .affectedCount()
620 .build(),
621 );
622 expect(
623 yield* update.execute({ id: bob.id, name: "Rolled back" }),
624 ).toEqual({ affectedRows: 1 });
625 const read = yield* tx.prepare(
626 { id: "pg/int4@1" },
627 (sql, params) =>
628 sql.public.user
629 .select("name")
630 .where((fields, fns) => fns.eq(fields.id, params.id))
631 .build(),
632 );
633 expect(yield* read.query({ id: bob.id })).toEqual([
634 { name: "Rolled back" },
635 ]);
636 return yield* tx.rollback();
637 }),
638 )
639 .pipe(
640 Effect.as("committed" as const),
641 Effect.catchTag("Prisma.RollbackError", () =>
642 Effect.succeed("rolled-back" as const),
643 ),
644 );
645 expect(rolledBack).toEqual("rolled-back");
646 expect(
647 (yield* db.orm.public.User.where({ id: bob.id }).first())?.name,
648 ).toBe("Prepared Bob");
649 expect(
650 yield* db.orm.public.Post.where({ title: "rollback-post" }).all(),
651 ).toHaveLength(0);
652
653 // transaction: a failing effect rolls back too
654 const failed = yield* Effect.result(
655 db.transaction((tx) =>
656 Effect.gen(function* () {
657 yield* tx.orm.public.Post.create({
658 title: "failed-post",
659 authorId: bob.id,
660 });
661 // unique violation aborts the transaction
662 yield* tx.orm.public.User.create({
663 email: "bob@example.com",
664 });
665 }),
666 ),
667 );
668 expect(Result.isFailure(failed)).toBe(true);
669 expect(
670 yield* db.orm.public.Post.where({ title: "failed-post" }).all(),
671 ).toHaveLength(0);
672 return prepared;
673 }),
674 );
675
676 const later = yield* statement
677 .query({ email: "bob@example.com" })
678 .pipe(Effect.scoped);
679 expect(later.map((row) => row.email)).toEqual(["bob@example.com"]);
680 const concurrent = yield* Effect.all(
681 ["bob@example.com", "missing@example.com"].map((email) =>
682 statement.query({ email }).pipe(Effect.scoped),
683 ),
684 { concurrency: 2 },
685 );
686 expect(concurrent.map((rows) => rows.length)).toEqual([1, 0]);
687
688 yield* stack.destroy();
689 }),
690 {
691 tags: [
692 "provider:neon",
693 "provider:neon:branch",
694 "provider:neon:project",
695 "provider:prisma",
696 "provider:prisma:orm",
697 "live",
698 ],
699 timeout: HOOK_TIMEOUT,
700 },
701);