1import * as Drizzle from "@/Drizzle";
2import * as Provider from "@/Provider";
3import * as Stack from "@/Stack";
4import { State } from "@/State";
5import * as Test from "@/Test/Alchemy";
6import { expect } from "alchemy-test";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Path from "effect/Path";
10import * as Result from "effect/Result";
11
12const { test } = Test.make({ providers: Drizzle.providers() });
13
14const 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.
18const SCHEMA_SOURCE = `
19import { pgTable, serial, text } from "drizzle-orm/pg-core";
20
21export const users = pgTable("users", {
22 id: serial("id").primaryKey(),
23 name: text("name").notNull(),
24});
25`;
26
27const 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
31const SQLITE_SCHEMA_SOURCE = `
32import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
33
34export const users = sqliteTable("users", {
35 id: integer("id").primaryKey(),
36 name: text("name").notNull(),
37});
38`;
39
40const SQLITE_DRIFTED_SCHEMA_SOURCE =
41 SQLITE_SCHEMA_SOURCE +
42 `
43export const posts = sqliteTable("posts", {
44 id: integer("id").primaryKey(),
45 title: text("title").notNull(),
46});
47`;
48
49const SQLITE_RENAMED_COLUMN_SCHEMA_SOURCE = `
50import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
51
52export const users = sqliteTable("users", {
53 id: integer("id").primaryKey(),
54 fullName: text("full_name").notNull(),
55});
56`;
57
58const 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
79const 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.
91const 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
118const 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
125test.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
145test.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
175test.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
213test.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
228test.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
256test.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
286test.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
329test.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);