History

Fix storage test boundary lint

Rhys Sullivan committed 6 months ago

Commit 89bc9c91 parent b039b6e2 files · +137 −180Browse files

Unified · Split

2 files
  1. packages/core/storage-core/src/testing/conformance.ts +120 −138
  2. packages/core/storage-core/src/testing/memory.ts +17 −42
packages/core/storage-core/src/testing/conformance.ts+120−138
@@ -13,7 +13,7 @@ · 12 unmodified lines
13 13
14import { describe, it } from "@effect/vitest";14import { describe, it } from "@effect/vitest";
15import { expect } from "@effect/vitest";15import { expect } from "@effect/vitest";
16import { Effect, Result } from "effect";16import { Data, Effect, Result } from "effect";
17 17
18import type { DBAdapter } from "../adapter";18import type { DBAdapter } from "../adapter";
19import type { DBSchema } from "../schema";19import 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
78class TransactionRollbackTestError extends Data.TaggedError("TransactionRollbackTestError")<{}> {}
79
78// ---------------------------------------------------------------------------80// ---------------------------------------------------------------------------
79// Suite81// Suite
80// ---------------------------------------------------------------------------82// ---------------------------------------------------------------------------
81 83
82export const runAdapterConformance = (84export 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 split568 // [{priority=1, AND}, {priority=10, OR}, {enabled=true, AND}],
579 // into two groups, recombined as `(AND…) AND (OR…)`. For569 // 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)` which571 // would give `((priority=1 OR priority=10) AND enabled=true)`
582 // can never match a single row — while a left-to-right fold572 // 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) AND593 // priority=10 check) and `rhs` has priority=10 (fails the
604 // (priority=10). `lhs` has priority=1 (fails the OR group's594 // AND group's priority=1 check) — both reject.
605 // priority=10 check) and `rhs` has priority=10 (fails the595 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", () =>
packages/core/storage-core/src/testing/memory.ts+17−42
@@ -31,11 +31,7 @@ type Row = Record<string, unknown>; · 30 unmodified lines
31type Store = Record<string, Row[]>;31type Store = Record<string, Row[]>;
32type Comparable = string | number | boolean | Date;32type Comparable = string | number | boolean | Date;
33 33
34const compare = (34const compare = (a: unknown, b: unknown, op: "gt" | "gte" | "lt" | "lte"): boolean => {
35 a: unknown,
36 b: unknown,
37 op: "gt" | "gte" | "lt" | "lte",
38): boolean => {
39 if (35 if (
40 !(36 !(
41 typeof a === "string" ||37 typeof a === "string" ||
@@ -43,12 +39,7 @@ const compare = ( · 1 unmodified line
43 typeof a === "boolean" ||39 typeof a === "boolean" ||
44 a instanceof Date40 a instanceof Date
45 ) ||41 ) ||
46 !(42 !(typeof b === "string" || typeof b === "number" || typeof b === "boolean" || b instanceof Date)
47 typeof b === "string" ||
48 typeof b === "number" ||
49 typeof b === "boolean" ||
50 b instanceof Date
51 )
52 ) {43 ) {
53 return false;44 return false;
54 }45 }
@@ -74,27 +65,25 @@ const evalClause = (record: Row, clause: CleanedWhere): boolean => { · 19 unmodified lines
74 const isInsensitive =65 const isInsensitive =
75 mode === "insensitive" &&66 mode === "insensitive" &&
76 (typeof value === "string" ||67 (typeof value === "string" ||
77 (Array.isArray(value) &&68 (Array.isArray(value) && (value as unknown[]).every((v) => typeof v === "string")));
78 (value as unknown[]).every((v) => typeof v === "string")));
79 69
80 const lhs = record[field];70 const lhs = record[field];
81 const lowerStr = (v: unknown) =>71 const lowerStr = (v: unknown) => (typeof v === "string" ? v.toLowerCase() : v);
82 typeof v === "string" ? v.toLowerCase() : v;
83 72
84 const cmp = (a: unknown, b: unknown): boolean =>73 const cmp = (a: unknown, b: unknown): boolean =>
85 isInsensitive ? lowerStr(a) === lowerStr(b) : a === b;74 isInsensitive ? lowerStr(a) === lowerStr(b) : a === b;
86 switch (operator) {75 switch (operator) {
87 case "in":76 case "in":
77 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: sync test adapter predicate preserves invalid where-clause failure semantics
88 if (!Array.isArray(value)) throw new Error("Value must be an array");78 if (!Array.isArray(value)) throw new Error("Value must be an array");
89 return (value as unknown[]).some((v) => cmp(lhs, v));79 return (value as unknown[]).some((v) => cmp(lhs, v));
90 case "not_in":80 case "not_in":
81 // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: sync test adapter predicate preserves invalid where-clause failure semantics
91 if (!Array.isArray(value)) throw new Error("Value must be an array");82 if (!Array.isArray(value)) throw new Error("Value must be an array");
92 return !(value as unknown[]).some((v) => cmp(lhs, v));83 return !(value as unknown[]).some((v) => cmp(lhs, v));
93 case "contains": {84 case "contains": {
94 if (typeof lhs !== "string" || typeof value !== "string") return false;85 if (typeof lhs !== "string" || typeof value !== "string") return false;
95 return isInsensitive86 return isInsensitive ? lhs.toLowerCase().includes(value.toLowerCase()) : lhs.includes(value);
96 ? lhs.toLowerCase().includes(value.toLowerCase())
97 : lhs.includes(value);
98 }87 }
99 case "starts_with": {88 case "starts_with": {
100 if (typeof lhs !== "string" || typeof value !== "string") return false;89 if (typeof lhs !== "string" || typeof value !== "string") return false;
@@ -104,9 +93,7 @@ const evalClause = (record: Row, clause: CleanedWhere): boolean => { · 3 unmodified lines
104 }93 }
105 case "ends_with": {94 case "ends_with": {
106 if (typeof lhs !== "string" || typeof value !== "string") return false;95 if (typeof lhs !== "string" || typeof value !== "string") return false;
107 return isInsensitive96 return isInsensitive ? lhs.toLowerCase().endsWith(value.toLowerCase()) : lhs.endsWith(value);
108 ? lhs.toLowerCase().endsWith(value.toLowerCase())
109 : lhs.endsWith(value);
110 }97 }
111 case "ne":98 case "ne":
112 return !cmp(lhs, value);99 return !cmp(lhs, value);
@@ -135,14 +122,10 @@ const evalClause = (record: Row, clause: CleanedWhere): boolean => { · 22 unmodified lines
135const matchAll = (record: Row, where: readonly CleanedWhere[]): boolean => {122const matchAll = (record: Row, where: readonly CleanedWhere[]): boolean => {
136 if (where.length === 0) return true;123 if (where.length === 0) return true;
137 if (where.length === 1) return evalClause(record, where[0]!);124 if (where.length === 1) return evalClause(record, where[0]!);
138 const andGroup = where.filter(125 const andGroup = where.filter((w) => w.connector === "AND" || !w.connector);
139 (w) => w.connector === "AND" || !w.connector,
140 );
141 const orGroup = where.filter((w) => w.connector === "OR");126 const orGroup = where.filter((w) => w.connector === "OR");
142 const andResult =127 const andResult = andGroup.length === 0 ? true : andGroup.every((w) => evalClause(record, w));
143 andGroup.length === 0 ? true : andGroup.every((w) => evalClause(record, w));128 const orResult = orGroup.length === 0 ? true : orGroup.some((w) => evalClause(record, w));
144 const orResult =
145 orGroup.length === 0 ? true : orGroup.some((w) => evalClause(record, w));
146 return andResult && orResult;129 return andResult && orResult;
147};130};
148 131
@@ -167,9 +150,7 @@ export interface MakeMemoryAdapterOptions { · 18 unmodified lines
167 readonly generateId?: () => string;150 readonly generateId?: () => string;
168}151}
169 152
170export const makeMemoryAdapter = (153export const makeMemoryAdapter = (options: MakeMemoryAdapterOptions): DBAdapter => {
171 options: MakeMemoryAdapterOptions,
172): DBAdapter => {
173 let store: Store = {};154 let store: Store = {};
174 155
175 const tableFor = (model: string): Row[] => {156 const tableFor = (model: string): Row[] => {
@@ -186,9 +167,7 @@ export const makeMemoryAdapter = ( · 10 unmodified lines
186 const out: Row = { ...base };167 const out: Row = { ...base };
187 for (const [target, cfg] of Object.entries(join)) {168 for (const [target, cfg] of Object.entries(join)) {
188 const targetRows = tableFor(target);169 const targetRows = tableFor(target);
189 const matches = targetRows.filter(170 const matches = targetRows.filter((r) => r[cfg.on.to] === base[cfg.on.from]);
190 (r) => r[cfg.on.to] === base[cfg.on.from],
191 );
192 if (cfg.relation === "one-to-one") {171 if (cfg.relation === "one-to-one") {
193 out[target] = matches[0] ?? null;172 out[target] = matches[0] ?? null;
194 } else {173 } else {
@@ -328,11 +307,9 @@ export const makeMemoryAdapter = ( · 133 unmodified lines
328 307
329 // Snapshot-based transaction: clone on entry, restore on failure.308 // Snapshot-based transaction: clone on entry, restore on failure.
330 const txFn: DBAdapterFactoryConfig["transaction"] = <R, E>(309 const txFn: DBAdapterFactoryConfig["transaction"] = <R, E>(
331 cb: (trx: Parameters<DBAdapter["transaction"]>[0] extends (310 cb: (
332 t: infer T,311 trx: Parameters<DBAdapter["transaction"]>[0] extends (t: infer T) => unknown ? T : never,
333 ) => unknown312 ) => Effect.Effect<R, E>,
334 ? T
335 : never) => Effect.Effect<R, E>,
336 ) =>313 ) =>
337 Effect.gen(function* () {314 Effect.gen(function* () {
338 const snapshot = cloneStore(store);315 const snapshot = cloneStore(store);
@@ -353,9 +330,7 @@ export const makeMemoryAdapter = ( · 14 unmodified lines
353 supportsDates: true,330 supportsDates: true,
354 supportsBooleans: true,331 supportsBooleans: true,
355 supportsArrays: true,332 supportsArrays: true,
356 customIdGenerator: options.generateId333 customIdGenerator: options.generateId ? () => options.generateId!() : undefined,
357 ? () => options.generateId!()
358 : undefined,
359 transaction: txFn,334 transaction: txFn,
360 },335 },
361 adapter: custom,336 adapter: custom,