| 1 | import * as Drizzle from "@/Drizzle"; |
| 2 | import * as Provider from "@/Provider"; |
| 3 | import * as Stack from "@/Stack"; |
| 4 | import { State } from "@/State"; |
| 5 | import * as Test from "@/Test/Alchemy"; |
| 6 | import { expect } from "alchemy-test"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as FileSystem from "effect/FileSystem"; |
| 9 | import * as Path from "effect/Path"; |
| 10 | import * as Result from "effect/Result"; |
| 11 | |
| 12 | const { test } = Test.make({ providers: Drizzle.providers() }); |
| 13 | |
| 14 | const DRIZZLE_ORIGIN_UUID = "00000000-0000-0000-0000-000000000000"; |
| 15 | |
| 16 | // Minimal drizzle-orm schema source — enough for `generateDrizzleJson` |
| 17 | // to produce a non-empty snapshot. |
| 18 | const SCHEMA_SOURCE = ` |
| 19 | import { pgTable, serial, text } from "drizzle-orm/pg-core"; |
| 20 | |
| 21 | export const users = pgTable("users", { |
| 22 | id: serial("id").primaryKey(), |
| 23 | name: text("name").notNull(), |
| 24 | }); |
| 25 | `; |
| 26 | |
| 27 | const DRIFTED_SCHEMA_SOURCE = |
| 28 | SCHEMA_SOURCE + |
| 29 | `\nexport const posts = pgTable("posts", {\n id: serial("id").primaryKey(),\n title: text("title").notNull(),\n});\n`; |
| 30 | |
| 31 | const SQLITE_SCHEMA_SOURCE = ` |
| 32 | import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; |
| 33 | |
| 34 | export const users = sqliteTable("users", { |
| 35 | id: integer("id").primaryKey(), |
| 36 | name: text("name").notNull(), |
| 37 | }); |
| 38 | `; |
| 39 | |
| 40 | const SQLITE_DRIFTED_SCHEMA_SOURCE = |
| 41 | SQLITE_SCHEMA_SOURCE + |
| 42 | ` |
| 43 | export const posts = sqliteTable("posts", { |
| 44 | id: integer("id").primaryKey(), |
| 45 | title: text("title").notNull(), |
| 46 | }); |
| 47 | `; |
| 48 | |
| 49 | const SQLITE_RENAMED_COLUMN_SCHEMA_SOURCE = ` |
| 50 | import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; |
| 51 | |
| 52 | export const users = sqliteTable("users", { |
| 53 | id: integer("id").primaryKey(), |
| 54 | fullName: text("full_name").notNull(), |
| 55 | }); |
| 56 | `; |
| 57 | |
| 58 | const stageWorkspace = (initialSource: string) => |
| 59 | Effect.gen(function* () { |
| 60 | const fs = yield* FileSystem.FileSystem; |
| 61 | const path = yield* Path.Path; |
| 62 | // Stage inside the repo (not the OS temp dir): the schema module is |
| 63 | // loaded via dynamic `import()`, and bun resolves its bare |
| 64 | // `drizzle-orm/*` imports by walking up from the schema file — which |
| 65 | // only finds `node_modules` when the staging dir lives in the workspace. |
| 66 | const cwd = yield* Effect.sync(() => process.cwd()); |
| 67 | const tempParent = path.join(cwd, ".alchemy", "tmp"); |
| 68 | yield* fs.makeDirectory(tempParent, { recursive: true }); |
| 69 | const root = yield* fs.makeTempDirectory({ |
| 70 | directory: tempParent, |
| 71 | prefix: "alchemy-drizzle-schema-test-", |
| 72 | }); |
| 73 | const schemaPath = path.join(root, "schema.ts"); |
| 74 | yield* fs.writeFileString(schemaPath, initialSource); |
| 75 | const out = path.join(root, "migrations"); |
| 76 | return { root, out, schemaPath }; |
| 77 | }); |
| 78 | |
| 79 | const readMigrationDirs = (out: string) => |
| 80 | Effect.gen(function* () { |
| 81 | const fs = yield* FileSystem.FileSystem; |
| 82 | return (yield* fs.readDirectory(out)) |
| 83 | .filter((name) => /^\d+_/.test(name)) |
| 84 | .sort(); |
| 85 | }); |
| 86 | |
| 87 | // Returns snapshots in chain order (each entry's prevIds points at the one |
| 88 | // before it). Directory-name order is NOT reliable: two migrations generated |
| 89 | // within the same second share the timestamp prefix and then sort by the |
| 90 | // random name suffix. |
| 91 | const readSnapshots = (out: string) => |
| 92 | Effect.gen(function* () { |
| 93 | const fs = yield* FileSystem.FileSystem; |
| 94 | const path = yield* Path.Path; |
| 95 | const dirs = yield* readMigrationDirs(out); |
| 96 | const snapshots: Array<{ id: string; prevIds: string[] }> = []; |
| 97 | for (const dir of dirs) { |
| 98 | const text = yield* fs.readFileString( |
| 99 | path.join(out, dir, "snapshot.json"), |
| 100 | ); |
| 101 | snapshots.push( |
| 102 | yield* Effect.try({ |
| 103 | try: () => JSON.parse(text) as { id: string; prevIds: string[] }, |
| 104 | catch: (cause) => new Error(`Failed to parse snapshot: ${cause}`), |
| 105 | }), |
| 106 | ); |
| 107 | } |
| 108 | const byPrev = new Map(snapshots.map((s) => [s.prevIds[0], s])); |
| 109 | const ordered: typeof snapshots = []; |
| 110 | let cursor = byPrev.get("00000000-0000-0000-0000-000000000000"); |
| 111 | while (cursor !== undefined) { |
| 112 | ordered.push(cursor); |
| 113 | cursor = byPrev.get(cursor.id); |
| 114 | } |
| 115 | return ordered.length === snapshots.length ? ordered : snapshots; |
| 116 | }); |
| 117 | |
| 118 | const getStatus = Effect.fn(function* (fqn: string) { |
| 119 | const state = yield* yield* State; |
| 120 | const stk = yield* Stack.Stack; |
| 121 | const s = yield* state.get({ stack: stk.name, stage: stk.stage, fqn }); |
| 122 | return s?.status; |
| 123 | }); |
| 124 | |
| 125 | test.provider( |
| 126 | "initial snapshot starts from the Drizzle origin", |
| 127 | (stack) => |
| 128 | Effect.gen(function* () { |
| 129 | const ws = yield* stageWorkspace(SCHEMA_SOURCE); |
| 130 | |
| 131 | yield* stack.deploy( |
| 132 | Drizzle.Schema("app-schema", { |
| 133 | schema: ws.schemaPath, |
| 134 | out: ws.out, |
| 135 | }), |
| 136 | ); |
| 137 | |
| 138 | const snapshots = yield* readSnapshots(ws.out); |
| 139 | expect(snapshots).toHaveLength(1); |
| 140 | expect(snapshots[0]?.prevIds).toEqual([DRIZZLE_ORIGIN_UUID]); |
| 141 | }), |
| 142 | { tags: ["unit", "local"] }, |
| 143 | ); |
| 144 | |
| 145 | test.provider( |
| 146 | "repeated deploys with no schema drift produce noop, not update (regression: forced update cascaded into Neon.Branch)", |
| 147 | (stack) => |
| 148 | Effect.gen(function* () { |
| 149 | const ws = yield* stageWorkspace(SCHEMA_SOURCE); |
| 150 | |
| 151 | yield* stack.deploy( |
| 152 | Drizzle.Schema("app-schema", { |
| 153 | schema: ws.schemaPath, |
| 154 | out: ws.out, |
| 155 | }), |
| 156 | ); |
| 157 | expect(yield* getStatus("app-schema")).toEqual("created"); |
| 158 | |
| 159 | // Second deploy with the same schema. Before the fix, Schema.diff |
| 160 | // returned `{ action: "update" }` unconditionally, so status would |
| 161 | // flip to "updated" and downstream resources (e.g. Neon.Branch) |
| 162 | // would see `schema.out` as an unresolved Output during plan and |
| 163 | // cascade into their own spurious updates. |
| 164 | yield* stack.deploy( |
| 165 | Drizzle.Schema("app-schema", { |
| 166 | schema: ws.schemaPath, |
| 167 | out: ws.out, |
| 168 | }), |
| 169 | ); |
| 170 | expect(yield* getStatus("app-schema")).toEqual("created"); |
| 171 | }), |
| 172 | { tags: ["unit", "local"] }, |
| 173 | ); |
| 174 | |
| 175 | test.provider( |
| 176 | "deploy after a real schema change updates the resource", |
| 177 | (stack) => |
| 178 | Effect.gen(function* () { |
| 179 | const fs = yield* FileSystem.FileSystem; |
| 180 | const path = yield* Path.Path; |
| 181 | const ws = yield* stageWorkspace(SCHEMA_SOURCE); |
| 182 | |
| 183 | const initial = yield* stack.deploy( |
| 184 | Drizzle.Schema("app-schema", { |
| 185 | schema: ws.schemaPath, |
| 186 | out: ws.out, |
| 187 | }), |
| 188 | ); |
| 189 | const [initialSnapshot] = yield* readSnapshots(ws.out); |
| 190 | |
| 191 | // Write the drifted schema as a *new* file so the dynamic import |
| 192 | // cache doesn't hand us the original module. |
| 193 | const driftedSchemaPath = path.join(ws.root, "schema-drifted.ts"); |
| 194 | yield* fs.writeFileString(driftedSchemaPath, DRIFTED_SCHEMA_SOURCE); |
| 195 | yield* Effect.sleep("1 second"); |
| 196 | |
| 197 | const drifted = yield* stack.deploy( |
| 198 | Drizzle.Schema("app-schema", { |
| 199 | schema: driftedSchemaPath, |
| 200 | out: ws.out, |
| 201 | }), |
| 202 | ); |
| 203 | |
| 204 | expect(yield* getStatus("app-schema")).toEqual("updated"); |
| 205 | expect(drifted.snapshotHash).not.toEqual(initial.snapshotHash); |
| 206 | const snapshots = yield* readSnapshots(ws.out); |
| 207 | expect(snapshots).toHaveLength(2); |
| 208 | expect(snapshots[1]?.prevIds).toEqual([initialSnapshot?.id]); |
| 209 | }), |
| 210 | { tags: ["unit", "local"] }, |
| 211 | ); |
| 212 | |
| 213 | test.provider( |
| 214 | "list returns [] (non-listable local build artifact)", |
| 215 | (stack) => |
| 216 | Effect.gen(function* () { |
| 217 | yield* stack.destroy(); |
| 218 | |
| 219 | const provider = yield* Provider.findProvider(Drizzle.Schema); |
| 220 | const all = yield* provider.list(); |
| 221 | expect(all).toEqual([]); |
| 222 | |
| 223 | yield* stack.destroy(); |
| 224 | }), |
| 225 | { tags: ["unit", "local"] }, |
| 226 | ); |
| 227 | |
| 228 | test.provider( |
| 229 | "sqlite schemas generate migrations through the CLI fallback", |
| 230 | (stack) => |
| 231 | Effect.gen(function* () { |
| 232 | const ws = yield* stageWorkspace(SQLITE_SCHEMA_SOURCE); |
| 233 | |
| 234 | yield* stack.deploy( |
| 235 | Drizzle.Schema("sqlite-schema", { |
| 236 | dialect: "sqlite", |
| 237 | schema: ws.schemaPath, |
| 238 | out: ws.out, |
| 239 | }), |
| 240 | ); |
| 241 | |
| 242 | const dirs = yield* readMigrationDirs(ws.out); |
| 243 | expect(dirs).toHaveLength(1); |
| 244 | // The CLI owns the real write path for sqlite fallback; it should keep |
| 245 | // drizzle-kit's generated migration directory names instead of |
| 246 | // Alchemy's programmatic `<timestamp>_migration` convention. |
| 247 | expect(dirs[0]).not.toMatch(/_migration$/); |
| 248 | |
| 249 | const snapshots = yield* readSnapshots(ws.out); |
| 250 | expect(snapshots).toHaveLength(1); |
| 251 | expect(snapshots[0]?.prevIds).toEqual([DRIZZLE_ORIGIN_UUID]); |
| 252 | }), |
| 253 | { tags: ["unit", "local"] }, |
| 254 | ); |
| 255 | |
| 256 | test.provider( |
| 257 | "sqlite CLI fallback repeated deploys with no drift stay noop", |
| 258 | (stack) => |
| 259 | Effect.gen(function* () { |
| 260 | const ws = yield* stageWorkspace(SQLITE_SCHEMA_SOURCE); |
| 261 | |
| 262 | yield* stack.deploy( |
| 263 | Drizzle.Schema("sqlite-schema", { |
| 264 | dialect: "sqlite", |
| 265 | schema: ws.schemaPath, |
| 266 | out: ws.out, |
| 267 | }), |
| 268 | ); |
| 269 | const initialDirs = yield* readMigrationDirs(ws.out); |
| 270 | expect(yield* getStatus("sqlite-schema")).toEqual("created"); |
| 271 | |
| 272 | yield* stack.deploy( |
| 273 | Drizzle.Schema("sqlite-schema", { |
| 274 | dialect: "sqlite", |
| 275 | schema: ws.schemaPath, |
| 276 | out: ws.out, |
| 277 | }), |
| 278 | ); |
| 279 | |
| 280 | expect(yield* getStatus("sqlite-schema")).toEqual("created"); |
| 281 | expect(yield* readMigrationDirs(ws.out)).toEqual(initialDirs); |
| 282 | }), |
| 283 | { tags: ["unit", "local"] }, |
| 284 | ); |
| 285 | |
| 286 | test.provider( |
| 287 | "sqlite CLI fallback updates after schema drift", |
| 288 | (stack) => |
| 289 | Effect.gen(function* () { |
| 290 | const fs = yield* FileSystem.FileSystem; |
| 291 | const path = yield* Path.Path; |
| 292 | const ws = yield* stageWorkspace(SQLITE_SCHEMA_SOURCE); |
| 293 | |
| 294 | yield* stack.deploy( |
| 295 | Drizzle.Schema("sqlite-schema", { |
| 296 | dialect: "sqlite", |
| 297 | schema: ws.schemaPath, |
| 298 | out: ws.out, |
| 299 | }), |
| 300 | ); |
| 301 | const [initialSnapshot] = yield* readSnapshots(ws.out); |
| 302 | |
| 303 | const driftedSchemaPath = path.join(ws.root, "schema-sqlite-drifted.ts"); |
| 304 | yield* fs.writeFileString( |
| 305 | driftedSchemaPath, |
| 306 | SQLITE_DRIFTED_SCHEMA_SOURCE, |
| 307 | ); |
| 308 | |
| 309 | yield* stack.deploy( |
| 310 | Drizzle.Schema("sqlite-schema", { |
| 311 | dialect: "sqlite", |
| 312 | schema: driftedSchemaPath, |
| 313 | out: ws.out, |
| 314 | }), |
| 315 | ); |
| 316 | |
| 317 | expect(yield* getStatus("sqlite-schema")).toEqual("updated"); |
| 318 | const dirs = yield* readMigrationDirs(ws.out); |
| 319 | expect(dirs).toHaveLength(2); |
| 320 | expect(dirs.every((dir) => !dir.endsWith("_migration"))).toBe(true); |
| 321 | |
| 322 | const snapshots = yield* readSnapshots(ws.out); |
| 323 | expect(snapshots).toHaveLength(2); |
| 324 | expect(snapshots[1]?.prevIds).toEqual([initialSnapshot?.id]); |
| 325 | }), |
| 326 | { tags: ["unit", "local"] }, |
| 327 | ); |
| 328 | |
| 329 | test.provider( |
| 330 | "sqlite CLI fallback refuses ambiguous drift without a TTY", |
| 331 | (stack) => |
| 332 | Effect.gen(function* () { |
| 333 | const fs = yield* FileSystem.FileSystem; |
| 334 | const path = yield* Path.Path; |
| 335 | const ws = yield* stageWorkspace(SQLITE_SCHEMA_SOURCE); |
| 336 | |
| 337 | yield* stack.deploy( |
| 338 | Drizzle.Schema("sqlite-schema", { |
| 339 | dialect: "sqlite", |
| 340 | schema: ws.schemaPath, |
| 341 | out: ws.out, |
| 342 | }), |
| 343 | ); |
| 344 | const initialDirs = yield* readMigrationDirs(ws.out); |
| 345 | |
| 346 | const renamedSchemaPath = path.join(ws.root, "schema-sqlite-renamed.ts"); |
| 347 | yield* fs.writeFileString( |
| 348 | renamedSchemaPath, |
| 349 | SQLITE_RENAMED_COLUMN_SCHEMA_SOURCE, |
| 350 | ); |
| 351 | yield* Effect.sleep("1 second"); |
| 352 | |
| 353 | // A column rename is ambiguous (rename vs drop+create) and the chosen |
| 354 | // SQL is applied to the real database later in the same deploy, so the |
| 355 | // non-interactive CLI must fail with guidance instead of deciding. |
| 356 | const result = yield* Effect.result( |
| 357 | stack.deploy( |
| 358 | Drizzle.Schema("sqlite-schema", { |
| 359 | dialect: "sqlite", |
| 360 | schema: renamedSchemaPath, |
| 361 | out: ws.out, |
| 362 | }), |
| 363 | ), |
| 364 | ); |
| 365 | |
| 366 | expect(Result.isFailure(result)).toBe(true); |
| 367 | if (Result.isFailure(result)) { |
| 368 | expect(String(result.failure)).toContain( |
| 369 | "drizzle-kit needs a decision", |
| 370 | ); |
| 371 | } |
| 372 | // No migration was written for the undecided drift. |
| 373 | expect(yield* readMigrationDirs(ws.out)).toEqual(initialDirs); |
| 374 | }), |
| 375 | { tags: ["unit", "local"] }, |
| 376 | ); |