| 1 | import { |
| 2 | detectLayout, |
| 3 | inlineSqlParams, |
| 4 | normalizeMigrationsInput, |
| 5 | readDrizzleDirRecords, |
| 6 | readFlatRecords, |
| 7 | resolveMigrations, |
| 8 | timestampPrefixMillis, |
| 9 | } from "@/SQL/Migrations/index.ts"; |
| 10 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 11 | import { describe as plainDescribe, expect, layer, test } from "alchemy-test"; |
| 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as Result from "effect/Result"; |
| 14 | |
| 15 | const fixture = (name: string) => |
| 16 | new URL(`./fixtures/${name}`, import.meta.url).pathname; |
| 17 | |
| 18 | const describe = layer(NodeServices.layer); |
| 19 | |
| 20 | describe("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 | |
| 76 | describe("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 | |
| 108 | plainDescribe("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 | |
| 152 | plainDescribe("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 | |
| 183 | plainDescribe("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 | }); |