1import {
2 detectLayout,
3 inlineSqlParams,
4 normalizeMigrationsInput,
5 readDrizzleDirRecords,
6 readFlatRecords,
7 resolveMigrations,
8 timestampPrefixMillis,
9} from "@/SQL/Migrations/index.ts";
10import * as NodeServices from "@effect/platform-node/NodeServices";
11import { describe as plainDescribe, expect, layer, test } from "alchemy-test";
12import * as Effect from "effect/Effect";
13import * as Result from "effect/Result";
14
15const fixture = (name: string) =>
16 new URL(`./fixtures/${name}`, import.meta.url).pathname;
17
18const describe = layer(NodeServices.layer);
19
20describe("detectLayout", (it) => {
21 it.effect(
22 "classifies drizzle-kit v1 dirs as directory layout",
23 () =>
24 Effect.gen(function* () {
25 expect(yield* detectLayout(fixture("drizzle-v1"))).toBe("directory");
26 }),
27 { tags: ["unit", "local"] },
28 );
29
30 it.effect(
31 "classifies Prisma dirs as directory layout too",
32 () =>
33 // Prisma and drizzle-v1 share the `<ts>_<name>/migration.sql` shape;
34 // both key records by directory name.
35 Effect.gen(function* () {
36 expect(yield* detectLayout(fixture("prisma"))).toBe("directory");
37 }),
38 { tags: ["unit", "local"] },
39 );
40
41 it.effect(
42 "classifies flat .sql directories",
43 () =>
44 Effect.gen(function* () {
45 expect(yield* detectLayout(fixture("flat"))).toBe("flat");
46 }),
47 { tags: ["unit", "local"] },
48 );
49
50 it.effect(
51 "fails on drizzle-v0 layouts with the drizzle-kit up hint",
52 () =>
53 Effect.gen(function* () {
54 const result = yield* Effect.result(
55 detectLayout(fixture("drizzle-v0")),
56 );
57 expect(Result.isFailure(result)).toBe(true);
58 if (Result.isFailure(result)) {
59 expect(result.failure._tag).toBe("DrizzleV0LayoutError");
60 expect(result.failure.message).toContain("drizzle-kit up");
61 }
62 }),
63 { tags: ["unit", "local"] },
64 );
65
66 it.effect(
67 "treats a missing directory as flat",
68 () =>
69 Effect.gen(function* () {
70 expect(yield* detectLayout(fixture("does-not-exist"))).toBe("flat");
71 }),
72 { tags: ["unit", "local"] },
73 );
74});
75
76describe("readers", (it) => {
77 it.effect(
78 "directory-layout records are keyed by directory name",
79 () =>
80 Effect.gen(function* () {
81 const records = yield* readDrizzleDirRecords(fixture("drizzle-v1"));
82 expect(records.map((r) => r.name)).toEqual([
83 "20240101000000_init",
84 "20240102000000_add_posts",
85 ]);
86 expect(records[0].createdAtMillis).toBe(Date.UTC(2024, 0, 1));
87 // statement-breakpoint splitting
88 expect(records[0].statements.length).toBe(2);
89 expect(records[0].hash).toMatch(/^[0-9a-f]{64}$/);
90 }),
91 { tags: ["unit", "local"] },
92 );
93
94 it.effect(
95 "flat records are keyed by relative file path",
96 () =>
97 Effect.gen(function* () {
98 const records = yield* readFlatRecords(fixture("flat"));
99 expect(records.map((r) => r.name)).toEqual([
100 "0001_users.sql",
101 "0002_posts.sql",
102 ]);
103 }),
104 { tags: ["unit", "local"] },
105 );
106});
107
108plainDescribe("resolveMigrations", { tags: ["local"] }, () => {
109 test(
110 "defaults to __alchemy_migrations",
111 () => {
112 expect(resolveMigrations({ input: { dir: "./m" }, stamped: {} })).toEqual(
113 {
114 dir: "./m",
115 table: "__alchemy_migrations",
116 },
117 );
118 },
119 { tags: ["unit"] },
120 );
121
122 test(
123 "a table persisted by a prior deploy wins over the default",
124 () => {
125 // Pre-registry deploys persisted their table name (d1_migrations,
126 // neon_migrations, custom names); honoring it keeps them converging
127 // against the same table, upgraded in place.
128 expect(
129 resolveMigrations({
130 input: { dir: "./m" },
131 stamped: { table: "neon_migrations" },
132 }).table,
133 ).toBe("neon_migrations");
134 },
135 { tags: ["unit"] },
136 );
137
138 test(
139 "an explicit table always wins",
140 () => {
141 expect(
142 resolveMigrations({
143 input: { dir: "./m", table: "my_migrations" },
144 stamped: { table: "d1_migrations" },
145 }).table,
146 ).toBe("my_migrations");
147 },
148 { tags: ["unit"] },
149 );
150});
151
152plainDescribe("normalizeMigrationsInput", { tags: ["local"] }, () => {
153 test(
154 "string is a directory",
155 () => {
156 expect(normalizeMigrationsInput("./migrations")).toEqual({
157 dir: "./migrations",
158 });
159 },
160 { tags: ["unit"] },
161 );
162 test(
163 "Drizzle.Schema-shaped outputs are accepted structurally",
164 () => {
165 expect(normalizeMigrationsInput({ out: "./migrations" })).toEqual({
166 dir: "./migrations",
167 });
168 },
169 { tags: ["unit"] },
170 );
171 test(
172 "object form passes through",
173 () => {
174 expect(normalizeMigrationsInput({ dir: "./m", table: "t" })).toEqual({
175 dir: "./m",
176 table: "t",
177 });
178 },
179 { tags: ["unit"] },
180 );
181});
182
183plainDescribe("helpers", { tags: ["local"] }, () => {
184 test(
185 "timestampPrefixMillis parses drizzle dir prefixes",
186 () => {
187 expect(timestampPrefixMillis("20240101000000_init")).toBe(
188 Date.UTC(2024, 0, 1),
189 );
190 expect(timestampPrefixMillis("0001_users.sql")).toBeUndefined();
191 },
192 { tags: ["unit"] },
193 );
194
195 test(
196 "inlineSqlParams inlines ? placeholders outside quotes",
197 () => {
198 expect(
199 inlineSqlParams(
200 "INSERT INTO t (a, b) VALUES (?, ?);",
201 ["it's", 42],
202 "sqlite",
203 ),
204 ).toBe("INSERT INTO t (a, b) VALUES ('it''s', 42);");
205 expect(
206 inlineSqlParams(
207 "SELECT * FROM t WHERE a = 'lit?' AND b = ?;",
208 [1],
209 "sqlite",
210 ),
211 ).toBe("SELECT * FROM t WHERE a = 'lit?' AND b = 1;");
212 },
213 { tags: ["unit"] },
214 );
215
216 test(
217 "inlineSqlParams inlines $n placeholders for postgres",
218 () => {
219 expect(inlineSqlParams("SELECT $1, $2;", ["x", null], "postgres")).toBe(
220 "SELECT 'x', NULL;",
221 );
222 },
223 { tags: ["unit"] },
224 );
225});