| @@ -13,7 +13,7 @@ · 12 unmodified lines |
| 13 | 13 | |
| 14 | 14 | import { describe, it } from "@effect/vitest"; |
| 15 | 15 | import { expect } from "@effect/vitest"; |
| 16 | | −import { Effect, Result } from "effect"; |
| 16 | +import { Data, Effect, Result } from "effect"; |
| 17 | 17 | |
| 18 | 18 | import type { DBAdapter } from "../adapter"; |
| 19 | 19 | import type { DBSchema } from "../schema"; |
| @@ -75,14 +75,13 @@ export type WithAdapter = <A, E>( · 55 unmodified lines |
| 75 | 75 | fn: (adapter: DBAdapter) => Effect.Effect<A, E>, |
| 76 | 76 | ) => Effect.Effect<A, E | Error>; |
| 77 | 77 | |
| 78 | +class TransactionRollbackTestError extends Data.TaggedError("TransactionRollbackTestError")<{}> {} |
| 79 | + |
| 78 | 80 | // --------------------------------------------------------------------------- |
| 79 | 81 | // Suite |
| 80 | 82 | // --------------------------------------------------------------------------- |
| 81 | 83 | |
| 82 | | −export const runAdapterConformance = ( |
| 83 | | − name: string, |
| 84 | | − withAdapter: WithAdapter, |
| 85 | | −): void => { |
| 84 | +export const runAdapterConformance = (name: string, withAdapter: WithAdapter): void => { |
| 86 | 85 | describe(`conformance: ${name}`, () => { |
| 87 | 86 | const withDefaultsInput = ( |
| 88 | 87 | value: unknown, |
| @@ -136,9 +135,7 @@ export const runAdapterConformance = ( · 47 unmodified lines |
| 136 | 135 | expect(found!.id).toBe(created.id); |
| 137 | 136 | expect(found!.enabled).toBe(true); |
| 138 | 137 | expect(found!.createdAt instanceof Date).toBe(true); |
| 139 | | − expect(found!.createdAt.toISOString()).toBe( |
| 140 | | − "2026-04-15T00:00:00.000Z", |
| 141 | | − ); |
| 138 | + expect(found!.createdAt.toISOString()).toBe("2026-04-15T00:00:00.000Z"); |
| 142 | 139 | expect(found!.metadata).toEqual({ slug: "gh", tags: ["a", "b"] }); |
| 143 | 140 | }), |
| 144 | 141 | ), |
| @@ -161,7 +158,7 @@ export const runAdapterConformance = ( · 16 unmodified lines |
| 161 | 158 | }); |
| 162 | 159 | const found = yield* adapter.findOne<{ createdAt: Date }>({ |
| 163 | 160 | model: "source", |
| 164 | | − where: [{ field: "id", value: row.id as string }], |
| 161 | + where: [{ field: "id", value: row.id }], |
| 165 | 162 | }); |
| 166 | 163 | expect(found!.createdAt.toISOString()).toBe(d.toISOString()); |
| 167 | 164 | }), |
| @@ -381,10 +378,7 @@ export const runAdapterConformance = ( · 213 unmodified lines |
| 381 | 378 | model: "source", |
| 382 | 379 | where: [{ field: "priority", value: 5, operator: "gte" }], |
| 383 | 380 | }); |
| 384 | | − expect(highPriority.map((r) => r.name).sort()).toEqual([ |
| 385 | | − "github-edge", |
| 386 | | − "gitlab", |
| 387 | | − ]); |
| 381 | + expect(highPriority.map((r) => r.name).sort()).toEqual(["github-edge", "gitlab"]); |
| 388 | 382 | }), |
| 389 | 383 | ), |
| 390 | 384 | ); |
| @@ -516,9 +510,7 @@ export const runAdapterConformance = ( · 125 unmodified lines |
| 516 | 510 | update: { touchedAt: explicitDate }, |
| 517 | 511 | }); |
| 518 | 512 | expect(updated).not.toBeNull(); |
| 519 | | − expect(updated!.touchedAt.toISOString()).toBe( |
| 520 | | − explicitDate.toISOString(), |
| 521 | | − ); |
| 513 | + expect(updated!.touchedAt.toISOString()).toBe(explicitDate.toISOString()); |
| 522 | 514 | // Sanity: omitting touchedAt should trigger onUpdate. |
| 523 | 515 | const hookDriven = yield* adapter.update<{ |
| 524 | 516 | id: string; |
| @@ -530,9 +522,7 @@ export const runAdapterConformance = ( · 5 unmodified lines |
| 530 | 522 | update: { name: "rename" }, |
| 531 | 523 | }); |
| 532 | 524 | expect(hookDriven).not.toBeNull(); |
| 533 | | − expect(hookDriven!.touchedAt.toISOString()).toBe( |
| 534 | | − "2099-01-01T00:00:00.000Z", |
| 535 | | − ); |
| 525 | + expect(hookDriven!.touchedAt.toISOString()).toBe("2099-01-01T00:00:00.000Z"); |
| 536 | 526 | }), |
| 537 | 527 | ), |
| 538 | 528 | ); |
| @@ -546,7 +536,7 @@ export const runAdapterConformance = ( · 7 unmodified lines |
| 546 | 536 | Effect.gen(function* () { |
| 547 | 537 | yield* trx.create({ model: "tag", data: { label: "tx1" } }); |
| 548 | 538 | yield* trx.create({ model: "tag", data: { label: "tx2" } }); |
| 549 | | − return yield* Effect.fail(new Error("boom")); |
| 539 | + return yield* new TransactionRollbackTestError(); |
| 550 | 540 | }), |
| 551 | 541 | ) |
| 552 | 542 | .pipe(Effect.result); |
| @@ -571,128 +561,120 @@ export const runAdapterConformance = ( · 18 unmodified lines |
| 571 | 561 | ), |
| 572 | 562 | ); |
| 573 | 563 | |
| 574 | | − it.effect( |
| 575 | | − "where: mixed AND/OR grouping follows upstream split-group semantics", |
| 576 | | − () => |
| 577 | | − // Locks in better-auth drizzle adapter's `convertWhereClause` |
| 578 | | − // semantics: AND-connector clauses and OR-connector clauses split |
| 579 | | − // into two groups, recombined as `(AND…) AND (OR…)`. For |
| 580 | | − // [{priority=1, AND}, {priority=10, OR}, {enabled=true, AND}], |
| 581 | | − // that's `(priority=1 AND enabled=true) AND (priority=10)` which |
| 582 | | − // can never match a single row — while a left-to-right fold |
| 583 | | − // would give `((priority=1 OR priority=10) AND enabled=true)` |
| 584 | | − // and return two rows. We assert the upstream reading. |
| 585 | | − withAdapter((adapter) => |
| 586 | | − Effect.gen(function* () { |
| 587 | | − yield* adapter.createMany({ |
| 588 | | − model: "source", |
| 589 | | − data: [ |
| 590 | | − { name: "lhs", priority: 1, enabled: true }, |
| 591 | | − { name: "rhs", priority: 10, enabled: true }, |
| 592 | | − { name: "off", priority: 1, enabled: false }, |
| 593 | | − ], |
| 594 | | − }); |
| 595 | | − const rows = yield* adapter.findMany<{ name: string }>({ |
| 596 | | − model: "source", |
| 597 | | − where: [ |
| 598 | | − { field: "priority", value: 1, connector: "AND" }, |
| 599 | | − { field: "priority", value: 10, connector: "OR" }, |
| 600 | | − { field: "enabled", value: true, connector: "AND" }, |
| 601 | | − ], |
| 602 | | − }); |
| 603 | | − // Upstream split-group: (priority=1 AND enabled=true) AND |
| 604 | | − // (priority=10). `lhs` has priority=1 (fails the OR group's |
| 605 | | − // priority=10 check) and `rhs` has priority=10 (fails the |
| 606 | | − // AND group's priority=1 check) — both reject. |
| 607 | | − expect(rows.map((r) => r.name)).toEqual([]); |
| 608 | | − |
| 609 | | − // Sanity: a pure disjunction still works. |
| 610 | | − const both = yield* adapter.findMany<{ name: string }>({ |
| 611 | | − model: "source", |
| 612 | | − where: [ |
| 613 | | − { field: "priority", value: 1, connector: "OR" }, |
| 614 | | − { field: "priority", value: 10, connector: "OR" }, |
| 615 | | − ], |
| 616 | | − sortBy: { field: "name", direction: "asc" }, |
| 617 | | − }); |
| 618 | | − expect(both.map((r) => r.name)).toEqual(["lhs", "off", "rhs"]); |
| 619 | | − }), |
| 620 | | − ), |
| 564 | + it.effect("where: mixed AND/OR grouping follows upstream split-group semantics", () => |
| 565 | + // Locks in better-auth drizzle adapter's `convertWhereClause` |
| 566 | + // semantics: AND-connector clauses and OR-connector clauses split |
| 567 | + // into two groups, recombined as `(AND…) AND (OR…)`. For |
| 568 | + // [{priority=1, AND}, {priority=10, OR}, {enabled=true, AND}], |
| 569 | + // that's `(priority=1 AND enabled=true) AND (priority=10)` which |
| 570 | + // can never match a single row — while a left-to-right fold |
| 571 | + // would give `((priority=1 OR priority=10) AND enabled=true)` |
| 572 | + // and return two rows. We assert the upstream reading. |
| 573 | + withAdapter((adapter) => |
| 574 | + Effect.gen(function* () { |
| 575 | + yield* adapter.createMany({ |
| 576 | + model: "source", |
| 577 | + data: [ |
| 578 | + { name: "lhs", priority: 1, enabled: true }, |
| 579 | + { name: "rhs", priority: 10, enabled: true }, |
| 580 | + { name: "off", priority: 1, enabled: false }, |
| 581 | + ], |
| 582 | + }); |
| 583 | + const rows = yield* adapter.findMany<{ name: string }>({ |
| 584 | + model: "source", |
| 585 | + where: [ |
| 586 | + { field: "priority", value: 1, connector: "AND" }, |
| 587 | + { field: "priority", value: 10, connector: "OR" }, |
| 588 | + { field: "enabled", value: true, connector: "AND" }, |
| 589 | + ], |
| 590 | + }); |
| 591 | + // Upstream split-group: (priority=1 AND enabled=true) AND |
| 592 | + // (priority=10). `lhs` has priority=1 (fails the OR group's |
| 593 | + // priority=10 check) and `rhs` has priority=10 (fails the |
| 594 | + // AND group's priority=1 check) — both reject. |
| 595 | + expect(rows.map((r) => r.name)).toEqual([]); |
| 596 | + |
| 597 | + // Sanity: a pure disjunction still works. |
| 598 | + const both = yield* adapter.findMany<{ name: string }>({ |
| 599 | + model: "source", |
| 600 | + where: [ |
| 601 | + { field: "priority", value: 1, connector: "OR" }, |
| 602 | + { field: "priority", value: 10, connector: "OR" }, |
| 603 | + ], |
| 604 | + sortBy: { field: "name", direction: "asc" }, |
| 605 | + }); |
| 606 | + expect(both.map((r) => r.name)).toEqual(["lhs", "off", "rhs"]); |
| 607 | + }), |
| 608 | + ), |
| 621 | 609 | ); |
| 622 | 610 | |
| 623 | | − it.effect( |
| 624 | | − "findMany resolves join: source → source_tag (one-to-many)", |
| 625 | | − () => |
| 626 | | − withAdapter((adapter) => |
| 627 | | − Effect.gen(function* () { |
| 628 | | − const src = yield* adapter.create<{ id: string; name: string }>({ |
| 629 | | − model: "source", |
| 630 | | − data: { name: "joined-source" }, |
| 631 | | − }); |
| 632 | | − yield* adapter.createMany({ |
| 633 | | − model: "source_tag", |
| 634 | | − data: [ |
| 635 | | − { sourceId: src.id, note: "first" }, |
| 636 | | − { sourceId: src.id, note: "second" }, |
| 637 | | − ], |
| 638 | | − }); |
| 639 | | − |
| 640 | | − const many = yield* adapter.findMany<{ |
| 641 | | − id: string; |
| 642 | | − name: string; |
| 643 | | − source_tag: ReadonlyArray<{ note: string; sourceId: string }>; |
| 644 | | − }>({ |
| 645 | | − model: "source", |
| 646 | | − where: [{ field: "id", value: src.id }], |
| 647 | | − join: { source_tag: true }, |
| 648 | | − }); |
| 649 | | − expect(many).toHaveLength(1); |
| 650 | | − const parent = many[0]!; |
| 651 | | − expect(parent.name).toBe("joined-source"); |
| 652 | | − expect(Array.isArray(parent.source_tag)).toBe(true); |
| 653 | | − expect(parent.source_tag).toHaveLength(2); |
| 654 | | − expect( |
| 655 | | − parent.source_tag.map((t) => t.note).sort(), |
| 656 | | − ).toEqual(["first", "second"]); |
| 657 | | − }), |
| 658 | | − ), |
| 611 | + it.effect("findMany resolves join: source → source_tag (one-to-many)", () => |
| 612 | + withAdapter((adapter) => |
| 613 | + Effect.gen(function* () { |
| 614 | + const src = yield* adapter.create<{ id: string; name: string }>({ |
| 615 | + model: "source", |
| 616 | + data: { name: "joined-source" }, |
| 617 | + }); |
| 618 | + yield* adapter.createMany({ |
| 619 | + model: "source_tag", |
| 620 | + data: [ |
| 621 | + { sourceId: src.id, note: "first" }, |
| 622 | + { sourceId: src.id, note: "second" }, |
| 623 | + ], |
| 624 | + }); |
| 625 | + |
| 626 | + const many = yield* adapter.findMany<{ |
| 627 | + id: string; |
| 628 | + name: string; |
| 629 | + source_tag: ReadonlyArray<{ note: string; sourceId: string }>; |
| 630 | + }>({ |
| 631 | + model: "source", |
| 632 | + where: [{ field: "id", value: src.id }], |
| 633 | + join: { source_tag: true }, |
| 634 | + }); |
| 635 | + expect(many).toHaveLength(1); |
| 636 | + const parent = many[0]!; |
| 637 | + expect(parent.name).toBe("joined-source"); |
| 638 | + expect(Array.isArray(parent.source_tag)).toBe(true); |
| 639 | + expect(parent.source_tag).toHaveLength(2); |
| 640 | + expect(parent.source_tag.map((t) => t.note).sort()).toEqual(["first", "second"]); |
| 641 | + }), |
| 642 | + ), |
| 659 | 643 | ); |
| 660 | 644 | |
| 661 | | − it.effect( |
| 662 | | − "findOne resolves join: source_tag → source (one-to-one)", |
| 663 | | − () => |
| 664 | | − withAdapter((adapter) => |
| 665 | | − Effect.gen(function* () { |
| 666 | | − const src = yield* adapter.create<{ id: string; name: string }>({ |
| 667 | | − model: "source", |
| 668 | | − data: { name: "owner" }, |
| 669 | | − }); |
| 670 | | − const child = yield* adapter.create<{ |
| 671 | | − id: string; |
| 672 | | − sourceId: string; |
| 673 | | − note: string; |
| 674 | | − }>({ |
| 675 | | − model: "source_tag", |
| 676 | | − data: { sourceId: src.id, note: "only" }, |
| 677 | | − }); |
| 678 | | − |
| 679 | | − const found = yield* adapter.findOne<{ |
| 680 | | − id: string; |
| 681 | | − note: string; |
| 682 | | − sourceId: string; |
| 683 | | − source: { id: string; name: string } | null; |
| 684 | | − }>({ |
| 685 | | − model: "source_tag", |
| 686 | | − where: [{ field: "id", value: child.id }], |
| 687 | | − join: { source: true }, |
| 688 | | − }); |
| 689 | | − expect(found).not.toBeNull(); |
| 690 | | − expect(found!.note).toBe("only"); |
| 691 | | − expect(found!.source).not.toBeNull(); |
| 692 | | − expect(found!.source!.id).toBe(src.id); |
| 693 | | − expect(found!.source!.name).toBe("owner"); |
| 694 | | − }), |
| 695 | | − ), |
| 645 | + it.effect("findOne resolves join: source_tag → source (one-to-one)", () => |
| 646 | + withAdapter((adapter) => |
| 647 | + Effect.gen(function* () { |
| 648 | + const src = yield* adapter.create<{ id: string; name: string }>({ |
| 649 | + model: "source", |
| 650 | + data: { name: "owner" }, |
| 651 | + }); |
| 652 | + const child = yield* adapter.create<{ |
| 653 | + id: string; |
| 654 | + sourceId: string; |
| 655 | + note: string; |
| 656 | + }>({ |
| 657 | + model: "source_tag", |
| 658 | + data: { sourceId: src.id, note: "only" }, |
| 659 | + }); |
| 660 | + |
| 661 | + const found = yield* adapter.findOne<{ |
| 662 | + id: string; |
| 663 | + note: string; |
| 664 | + sourceId: string; |
| 665 | + source: { id: string; name: string } | null; |
| 666 | + }>({ |
| 667 | + model: "source_tag", |
| 668 | + where: [{ field: "id", value: child.id }], |
| 669 | + join: { source: true }, |
| 670 | + }); |
| 671 | + expect(found).not.toBeNull(); |
| 672 | + expect(found!.note).toBe("only"); |
| 673 | + expect(found!.source).not.toBeNull(); |
| 674 | + expect(found!.source!.id).toBe(src.id); |
| 675 | + expect(found!.source!.name).toBe("owner"); |
| 676 | + }), |
| 677 | + ), |
| 696 | 678 | ); |
| 697 | 679 | |
| 698 | 680 | it.effect("nested writes inside a transaction see the tx state", () => |