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