@alchemy.run/drizzle 2.0.0-beta.84 → 2.0.0-beta.86

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Schema.ts CHANGED
@@ -1,31 +1,186 @@
1
1
  import * as crypto from "node:crypto";
2
2
  import * as Artifacts from "alchemy/Artifacts";
3
3
  import { isResolved } from "alchemy/Diff";
4
+ import { Interaction } from "alchemy/Interaction";
4
5
  import * as Provider from "alchemy/Provider";
5
6
  import { Resource } from "alchemy/Resource";
6
- import { exec } from "alchemy/Util/exec";
7
- import { isNonInteractive } from "alchemy/Util/interactive";
7
+ import * as Data from "effect/Data";
8
8
  import * as Effect from "effect/Effect";
9
9
  import * as FileSystem from "effect/FileSystem";
10
+ import * as Match from "effect/Match";
11
+ import * as Option from "effect/Option";
10
12
  import * as Path from "effect/Path";
11
- import type { PlatformError } from "effect/PlatformError";
12
- import { ChildProcess } from "effect/process";
13
+ import * as Record from "effect/Record";
14
+ import * as S from "effect/Schema";
15
+ import * as Semaphore from "effect/Semaphore";
13
16
  import type { Providers } from "./Providers.ts";
14
17
 
15
18
  export type Dialect = "postgres" | "mysql" | "sqlite";
16
19
 
17
- type DrizzleSnapshot = {
18
- id?: string;
19
- prevIds?: string[];
20
+ // Unlisted fields are kept (the rest records), so a decoded snapshot can be
21
+ // written back whole.
22
+ const DdlEntry = S.StructWithRest(
23
+ S.Struct({
24
+ entityType: S.String,
25
+ schema: S.optional(S.String),
26
+ table: S.optional(S.String),
27
+ name: S.optional(S.String),
28
+ }),
29
+ [S.Record(S.String, S.Unknown)],
30
+ );
31
+ type DdlEntry = typeof DdlEntry.Type;
32
+
33
+ // A `snapshot.json`. Postgres folders hold one `base` snapshot followed by
34
+ // `diff` snapshots carrying `deltas`; mysql and sqlite snapshots are full.
35
+ const Snapshot = S.StructWithRest(
36
+ S.Struct({
37
+ type: S.optional(S.Literals(["base", "diff"])),
38
+ version: S.optional(S.String),
39
+ dialect: S.optional(S.String),
40
+ id: S.optional(S.String),
41
+ prevIds: S.String.pipe(S.Array, S.optional),
42
+ ddl: DdlEntry.pipe(S.Array, S.optional),
43
+ }),
44
+ [S.Record(S.String, S.Unknown)],
45
+ );
46
+ type Snapshot = typeof Snapshot.Type;
47
+
48
+ const decodeSnapshot = S.decodeEffect(S.fromJsonString(Snapshot));
49
+
50
+ // drizzle-kit's `MissingHint` / `Hint`, narrowed to what `generate` can ask:
51
+ // rename-or-create. Its `confirm_data_loss` questions need a live database
52
+ // (`push` checks whether tables are empty), so generate never raises them.
53
+ // Entity ids are tuples: `[name]`, `[schema, name]` or `[schema, table, name]`.
54
+ type MissingHint = {
55
+ readonly type: "rename_or_create";
56
+ readonly kind: string;
57
+ readonly entity: ReadonlyArray<string>;
20
58
  };
21
59
 
22
- type DrizzleKitApi = {
23
- generateDrizzleJson?: (
24
- imports: Record<string, unknown>,
25
- prevId?: string,
26
- schemaFilters?: string[],
27
- ) => Promise<unknown>;
28
- generateMigration?: (prev: unknown, cur: unknown) => Promise<string[]>;
60
+ type Hint =
61
+ | { type: "rename"; kind: string; from: ReadonlyArray<string>; to: ReadonlyArray<string> }
62
+ | { type: "create"; kind: string; entity: ReadonlyArray<string> };
63
+
64
+ type GenerateOptions = SchemaGenerateOptions & {
65
+ dialect: string;
66
+ schema: string;
67
+ out: string;
68
+ explain?: boolean;
69
+ hints?: Array<Hint>;
70
+ };
71
+
72
+ type UpOptions = { dialect: string; out: string };
73
+
74
+ type GenerateResult =
75
+ | { status: "ok" }
76
+ | { status: "no_changes" }
77
+ | { status: "missing_hints"; unresolved: ReadonlyArray<MissingHint> };
78
+
79
+ type UpResult = { status: "ok"; upgraded: ReadonlyArray<string> };
80
+
81
+ type CommandFailed = { status: "error"; error: unknown };
82
+
83
+ // The `drizzle-kit` commands Drizzle.Schema runs: `[options, result]`.
84
+ type Commands = {
85
+ generate: [GenerateOptions, GenerateResult];
86
+ up: [UpOptions, UpResult];
87
+ };
88
+
89
+ // `drizzle-kit/cli`: the commands as functions. They never prompt and report
90
+ // results (including unanswered questions) as values.
91
+ type DrizzleKitCli = {
92
+ [K in keyof Commands]: (options: Commands[K][0]) => Promise<Commands[K][1] | CommandFailed>;
93
+ };
94
+
95
+ // `drizzle-kit/payload/*`: snapshot builders. Only postgres has `reconstruct`.
96
+ type DrizzleKitPayload = {
97
+ generateDrizzleJson: (imports: Record.ReadonlyRecord<string, unknown>) => Promise<Snapshot>;
98
+ reconstruct?: (files: Record.ReadonlyRecord<string, Snapshot>, id?: string) => Promise<Snapshot>;
99
+ };
100
+
101
+ class DrizzleKitLoadFailed extends Data.TaggedError("DrizzleKitLoadFailed")<{
102
+ readonly module: string;
103
+ readonly cause: unknown;
104
+ }> {
105
+ override get message() {
106
+ return `Failed to load ${this.module} (is drizzle-kit installed?): ${this.cause instanceof Error ? this.cause.message : String(this.cause)}`;
107
+ }
108
+ }
109
+
110
+ class InvalidSnapshot extends Data.TaggedError("InvalidSnapshot")<{
111
+ readonly file: string;
112
+ readonly cause: S.SchemaError;
113
+ }> {
114
+ override get message() {
115
+ return `Invalid drizzle snapshot at ${this.file}: ${this.cause.message}`;
116
+ }
117
+ }
118
+
119
+ class SchemaImportFailed extends Data.TaggedError("SchemaImportFailed")<{
120
+ readonly schema: string;
121
+ readonly cause: unknown;
122
+ }> {
123
+ override get message() {
124
+ return `Failed to import schema at ${this.schema}: ${this.cause instanceof Error ? this.cause.message : String(this.cause)}`;
125
+ }
126
+ }
127
+
128
+ class DrizzleKitFailed extends Data.TaggedError("DrizzleKitFailed")<{
129
+ readonly command: string;
130
+ readonly cause: unknown;
131
+ }> {
132
+ override get message() {
133
+ // Thrown errors carry their own message; reported ones (`{ status:
134
+ // "error", error }`) are plain objects like `{ code, kind, snapshot }`.
135
+ const { cause } = this;
136
+ const detail = cause instanceof Error ? cause.message : JSON.stringify(cause);
137
+ return `drizzle-kit ${this.command} failed: ${detail}`;
138
+ }
139
+ }
140
+
141
+ class DrizzleKitExited extends Data.TaggedError("DrizzleKitExited")<{
142
+ readonly command: string;
143
+ }> {
144
+ override get message() {
145
+ return `drizzle-kit ${this.command} found errors in the schema (such as two columns with one name) and tried to exit the process`;
146
+ }
147
+ }
148
+
149
+ /** An ambiguous schema change with no terminal to ask. */
150
+ class UndecidedSchemaChange extends Data.TaggedError("UndecidedSchemaChange")<{
151
+ readonly props: SchemaProps;
152
+ readonly hint: MissingHint;
153
+ }> {
154
+ override get message() {
155
+ const { props, hint } = this;
156
+ return [
157
+ `${props.schema} has an ambiguous change that needs a decision:`,
158
+ ` ${formatEntity(hint.entity)}: created or renamed?`,
159
+ "Re-run in an interactive terminal to answer it.",
160
+ ].join("\n");
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Options passed through to `drizzle-kit generate`, matching the fields of
166
+ * the same name in `drizzle.config.ts`. `dialect`, `schema` and `out` come
167
+ * from {@link SchemaProps}.
168
+ */
169
+ export type SchemaGenerateOptions = {
170
+ /**
171
+ * Separate statements with `--> statement-breakpoint` markers. Set to
172
+ * `false` to match migrations generated with `breakpoints: false`.
173
+ *
174
+ * @default true
175
+ */
176
+ breakpoints?: boolean;
177
+ /**
178
+ * Generate even when migrations from parallel branches conflict, writing
179
+ * a merge migration. Not supported for postgres.
180
+ *
181
+ * @default false
182
+ */
183
+ ignoreConflicts?: boolean;
29
184
  };
30
185
 
31
186
  export type SchemaProps = {
@@ -39,8 +194,8 @@ export type SchemaProps = {
39
194
  */
40
195
  schema: string;
41
196
  /**
42
- * Output directory for generated migrations. Each migration is written as
43
- * `{out}/{timestamp}_migration/{migration.sql, snapshot.json}`. Pass this
197
+ * Output directory for generated migrations, in drizzle-kit's own layout:
198
+ * `{out}/{timestamp}_{name}/{migration.sql, snapshot.json}`. Pass this
44
199
  * through as a database resource's `migrations` prop (`Neon.Branch`,
45
200
  * `Fly.Postgres`, `Cloudflare.D1.Database`, …) to apply pending
46
201
  * migrations on deploy.
@@ -49,12 +204,17 @@ export type SchemaProps = {
49
204
  */
50
205
  out?: string;
51
206
  /**
52
- * SQL dialect to generate migrations for. Selects which `drizzle-kit/api-*`
53
- * module is loaded.
207
+ * SQL dialect to generate migrations for.
54
208
  *
55
209
  * @default "postgres"
56
210
  */
57
211
  dialect?: Dialect;
212
+ /**
213
+ * Options passed through to `drizzle-kit generate`.
214
+ *
215
+ * @example { breakpoints: false }
216
+ */
217
+ generate?: SchemaGenerateOptions;
58
218
  };
59
219
 
60
220
  export type Schema = Resource<
@@ -80,9 +240,10 @@ export type Schema = Resource<
80
240
  /**
81
241
  * A Drizzle schema managed as an Alchemy resource.
82
242
  *
83
- * Wraps drizzle-kit's programmatic API (`generateDrizzleJson` /
84
- * `generateMigration`) so migration SQL is regenerated as part of `alchemy
85
- * deploy` whenever the source schema changes. The output directory is
243
+ * Runs `drizzle-kit generate` through drizzle-kit's programmatic API so
244
+ * migration SQL is regenerated as part of `alchemy deploy` whenever the
245
+ * source schema changes. Ambiguous changes (a rename or a new column/table?)
246
+ * are asked with Alchemy's prompts during plan. The output directory is
86
247
  * intended to be passed straight to a database resource's `migrations`
87
248
  * prop, giving you a single deploy-driven flow:
88
249
  *
@@ -114,20 +275,75 @@ export type Schema = Resource<
114
275
  */
115
276
  export const Schema = Resource<Schema>("Drizzle.Schema");
116
277
 
117
- const dialectModule = (dialect: Dialect): string => {
118
- switch (dialect) {
119
- case "postgres":
120
- return "drizzle-kit/api-postgres";
121
- case "mysql":
122
- return "drizzle-kit/api-mysql";
123
- case "sqlite":
124
- return "drizzle-kit/api-sqlite";
125
- }
126
- };
278
+ const cliDialect = (props: SchemaProps) =>
279
+ props.dialect === undefined || props.dialect === "postgres" ? "postgresql" : props.dialect;
127
280
 
128
281
  const sha = (s: string) => crypto.createHash("sha256").update(s).digest("hex");
129
282
 
130
- const tsStamp = () => new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14);
283
+ const importModule = <A>(module: string) =>
284
+ Effect.tryPromise({
285
+ try: () => import(/* @vite-ignore */ module) as Promise<A>,
286
+ catch: (cause) => new DrizzleKitLoadFailed({ module, cause }),
287
+ });
288
+
289
+ // drizzle-kit runs in-process but calls `process.exit` on schema errors.
290
+ // Calls trap the exit and fail instead, one at a time since the trap swaps
291
+ // the global.
292
+ const exitTrap = Semaphore.makeUnsafe(1);
293
+
294
+ const callDrizzleKit = <A>(command: string, run: () => Promise<A>) =>
295
+ Effect.acquireUseRelease(
296
+ Effect.sync(() => {
297
+ // Saved only to restore `process.exit` verbatim, never called unbound.
298
+ // Read through `Reflect.get`: whether `typescript/unbound-method`
299
+ // flags `process.exit` depends on which `process` typing the linter's
300
+ // program resolves, which unrelated files change.
301
+ const trap = { exit: Reflect.get(process, "exit") as typeof process.exit, exited: false };
302
+ process.exit = () => {
303
+ trap.exited = true;
304
+ throw new Error("drizzle-kit exited");
305
+ };
306
+ return trap;
307
+ }),
308
+ (trap) =>
309
+ // `cli` commands catch the trap's throw and report it as an error result.
310
+ // Uninterruptible: the promise can't be cancelled, so the trap stays
311
+ // until it settles.
312
+ Effect.tryPromise({
313
+ try: run,
314
+ catch: (cause) =>
315
+ trap.exited
316
+ ? new DrizzleKitExited({ command })
317
+ : new DrizzleKitFailed({ command, cause }),
318
+ }).pipe(
319
+ Effect.flatMap((result) =>
320
+ trap.exited ? Effect.fail(new DrizzleKitExited({ command })) : Effect.succeed(result),
321
+ ),
322
+ Effect.uninterruptible,
323
+ ),
324
+ (trap) =>
325
+ Effect.sync(() => {
326
+ process.exit = trap.exit;
327
+ }),
328
+ ).pipe((effect) => exitTrap.withPermit(effect));
329
+
330
+ // A `drizzle-kit/cli` command as an Effect, failing on a thrown or reported error.
331
+ const command = <K extends keyof Commands>(name: K) =>
332
+ Effect.fn(`drizzleKit.${name}`)(function* (options: Commands[K][0]) {
333
+ const cli = yield* importModule<DrizzleKitCli>("drizzle-kit/cli");
334
+ const result = yield* callDrizzleKit(name, () => cli[name](options));
335
+ if (result.status === "error") {
336
+ return yield* new DrizzleKitFailed({ command: name, cause: result.error });
337
+ }
338
+ return result as Commands[K][1];
339
+ });
340
+
341
+ const drizzleKit = {
342
+ /** `drizzle-kit generate`; with `explain` a dry run that writes nothing. */
343
+ generate: command("generate"),
344
+ /** `drizzle-kit up`: upgrade snapshots to the current format. */
345
+ up: command("up"),
346
+ };
131
347
 
132
348
  export const SchemaProvider = () =>
133
349
  Provider.effect(
@@ -145,286 +361,218 @@ export const SchemaProvider = () =>
145
361
  const relativeOut = (abs: string) => path.relative(process.cwd(), abs);
146
362
 
147
363
  const resolveSchema = (p: SchemaProps) => path.resolve(process.cwd(), p.schema);
148
-
149
- const loadSchemaModule = (p: SchemaProps) =>
150
- Effect.gen(function* () {
151
- const schemaPath = resolveSchema(p);
152
- return yield* Effect.tryPromise({
153
- try: () => import(/* @vite-ignore */ schemaPath) as Promise<Record<string, unknown>>,
154
- catch: (cause) => new Error(`Failed to import schema at ${p.schema}: ${cause}`),
155
- });
156
- });
157
-
158
- const loadKit = (dialect: Dialect) =>
159
- Effect.tryPromise({
160
- try: () => import(/* @vite-ignore */ dialectModule(dialect)) as Promise<DrizzleKitApi>,
161
- catch: (cause) =>
162
- new Error(
163
- `Failed to load drizzle-kit/${dialect} (is drizzle-kit installed?): ${cause}`,
164
- ),
165
- });
166
-
167
- const drizzleKitBin = (dialect: Dialect) =>
168
- Effect.gen(function* () {
169
- const apiUrl = yield* Effect.try({
170
- try: () => import.meta.resolve(dialectModule(dialect)),
171
- catch: (cause) => new Error(`Failed to resolve drizzle-kit/${dialect}: ${cause}`),
172
- });
173
- const apiFileUrl = yield* Effect.try({
174
- try: () => new URL(apiUrl),
175
- catch: (cause) => new Error(`Failed to parse drizzle-kit/${dialect} URL: ${cause}`),
176
- });
177
- const apiPath = yield* path.fromFileUrl(apiFileUrl);
178
- return path.join(path.dirname(apiPath), "bin.cjs");
179
- });
180
-
181
- const runDrizzleGenerate = (props: SchemaProps, out: string) =>
182
- Effect.gen(function* () {
183
- const dialect = props.dialect ?? "postgres";
184
- const bin = yield* drizzleKitBin(dialect);
185
- const schemaPath = resolveSchema(props);
186
- const nodeExecPath = yield* Effect.sync(() => process.execPath);
187
- const nodeModulesPath = path.join(process.cwd(), "node_modules");
188
-
189
- // drizzle-kit globs the --schema/--out values; Windows backslashes
190
- // are treated as glob escapes and match nothing, so pass forward
191
- // slashes.
192
- const args = [
193
- bin,
194
- "generate",
195
- "--dialect",
196
- dialect === "postgres" ? "postgresql" : dialect,
197
- "--schema",
198
- schemaPath.replaceAll("\\", "/"),
199
- "--out",
200
- out.replaceAll("\\", "/"),
201
- ];
202
-
203
- const commandOptions = {
204
- cwd: process.cwd(),
205
- env: { NODE_PATH: nodeModulesPath },
206
- extendEnv: true,
207
- };
208
-
209
- // Hand the terminal to drizzle-kit only when this process may
210
- // prompt at all — the same detection (TTY, CI, `--no-input`,
211
- // `ALCHEMY_NO_TUI`, agent env) every other spawned command uses.
212
- if (!isNonInteractive() && process.stderr.isTTY) {
213
- const handle = yield* ChildProcess.make(nodeExecPath, args, {
214
- ...commandOptions,
215
- stdin: "inherit",
216
- stdout: "inherit",
217
- stderr: "inherit",
218
- }).pipe(
219
- Effect.mapError(
220
- (cause) => new Error(`drizzle-kit generate failed: ${String(cause)}`),
221
- ),
222
- );
223
- const exitCode = yield* handle.exitCode;
224
- if (exitCode !== 0) {
225
- return yield* Effect.fail(
226
- new Error(`drizzle-kit generate failed with exit ${exitCode}`),
227
- );
228
- }
229
- return;
230
- }
231
-
232
- const result = yield* exec(ChildProcess.make(nodeExecPath, args, commandOptions)).pipe(
233
- Effect.mapError((cause) => new Error(`drizzle-kit generate failed: ${String(cause)}`)),
234
- );
235
- if (result.exitCode === 0) return;
236
-
237
- // Since drizzle-kit 1.0.0-rc.4 the non-interactive CLI refuses
238
- // ambiguous decisions (rename-vs-create, data-loss confirmation)
239
- // with a `missing_hints` report on exit 2. These are deliberate
240
- // safety prompts — the generated SQL is applied to the real
241
- // database later in the same deploy — so we never answer them
242
- // automatically. Ask the user to decide.
243
- if (result.exitCode === 2) {
244
- return yield* Effect.fail(
245
- new Error(
246
- [
247
- `drizzle-kit needs a decision for ${props.schema} that cannot be made non-interactively (rename vs create, or a change that loses data):`,
248
- "",
249
- result.stdout.trim(),
250
- "",
251
- "To resolve, generate the migration yourself and commit it:",
252
- ` npx drizzle-kit generate --dialect ${dialect === "postgres" ? "postgresql" : dialect} --schema ${props.schema} --out ${props.out ?? "./migrations"}`,
253
- "then re-run the deploy (the schema resource will see no drift and apply the committed migration).",
254
- "Alternatively, run the deploy in a terminal to answer drizzle-kit's prompts interactively.",
255
- ].join("\n"),
256
- ),
257
- );
258
- }
259
-
260
- return yield* Effect.fail(
261
- new Error(`drizzle-kit generate failed: ${result.stdout}\n${result.stderr}`),
262
- );
263
- });
364
+ // drizzle-kit reads `schema` as a glob, where `\` is an escape.
365
+ const schemaGlob = (p: SchemaProps) => resolveSchema(p).replaceAll("\\", "/");
264
366
 
265
367
  // List `<ts>_*` migration directories under `out`, sorted by numeric
266
368
  // prefix. Returns an empty array if `out` doesn't exist.
267
- const listMigrationDirs = (out: string) =>
268
- Effect.gen(function* () {
269
- const exists = yield* fs.exists(out);
270
- if (!exists) return [] as string[];
271
- const entries = yield* fs.readDirectory(out);
272
- return entries.filter((name) => /^\d+_/.test(name)).sort();
273
- });
369
+ const listMigrationDirs = Effect.fn("Drizzle.listMigrationDirs")(function* (out: string) {
370
+ if (!(yield* fs.exists(out))) return [];
371
+ const entries = yield* fs.readDirectory(out);
372
+ return entries.filter((name) => /^\d+_/.test(name)).sort();
373
+ });
374
+
375
+ const readSnapshots = Effect.fn("Drizzle.readSnapshots")(function* (out: string) {
376
+ const entries = yield* Effect.forEach(yield* listMigrationDirs(out), (dir) =>
377
+ Effect.gen(function* () {
378
+ const file = path.join(out, dir, "snapshot.json");
379
+ if (!(yield* fs.exists(file))) return [];
380
+ const text = yield* fs.readFileString(file);
381
+ const snapshot = yield* decodeSnapshot(text).pipe(
382
+ Effect.mapError((cause) => new InvalidSnapshot({ file, cause })),
383
+ );
384
+ return [[dir, { snapshot, text }] as const];
385
+ }),
386
+ );
387
+ return Record.fromEntries(entries.flat());
388
+ });
274
389
 
275
390
  // The latest snapshot is the head of the `prevIds` chain — the one no
276
391
  // other snapshot points back to. Directory-name order alone is not
277
392
  // enough: two migrations generated within the same second share the
278
393
  // timestamp prefix, and the random name suffix then decides the sort.
279
- const readLatestSnapshot = (out: string) =>
280
- Effect.gen(function* () {
281
- const dirs = yield* listMigrationDirs(out);
282
- const entries: Array<{ snapshot: DrizzleSnapshot; hash: string }> = [];
283
- for (const dir of dirs) {
284
- const snapshotPath = path.join(out, dir, "snapshot.json");
285
- const exists = yield* fs.exists(snapshotPath);
286
- if (!exists) continue;
287
- const text = yield* fs.readFileString(snapshotPath);
288
- entries.push({
289
- snapshot: JSON.parse(text) as DrizzleSnapshot,
290
- hash: sha(text),
291
- });
292
- }
293
- if (entries.length === 0) return undefined;
294
- const referenced = new Set(entries.flatMap((entry) => entry.snapshot.prevIds ?? []));
295
- const head = entries.find(
296
- (entry) => entry.snapshot.id !== undefined && !referenced.has(entry.snapshot.id),
297
- );
298
- // Fall back to name order for snapshots without id/prevIds chains.
299
- return head ?? entries[entries.length - 1];
300
- });
301
-
302
- const detectDriftWithCli = (props: SchemaProps) =>
303
- Effect.gen(function* () {
304
- const out = resolveOut(props);
305
- const tmpOut = yield* fs.makeTempDirectory({
306
- prefix: "alchemy-drizzle-generate-",
307
- });
308
-
309
- return yield* Effect.gen(function* () {
310
- const outExists = yield* fs.exists(out);
311
- if (outExists) yield* copyDirectory(out, tmpOut);
312
-
313
- const before = yield* listMigrationDirs(tmpOut);
314
- yield* runDrizzleGenerate(props, tmpOut);
315
-
316
- const after = yield* listMigrationDirs(tmpOut);
317
- const latest = yield* readLatestSnapshot(tmpOut);
318
- const changed = after.some((dir) => !before.includes(dir));
319
-
320
- const prevEntry = outExists ? yield* readLatestSnapshot(out) : undefined;
321
-
322
- return {
323
- mode: "cli" as const,
324
- out,
325
- cur: latest?.snapshot,
326
- prevEntry,
327
- changed,
328
- };
329
- }).pipe(Effect.ensuring(fs.remove(tmpOut, { recursive: true }).pipe(Effect.ignore)));
330
- });
394
+ const readLatestSnapshot = Effect.fn("Drizzle.readLatestSnapshot")(function* (out: string) {
395
+ const entries = Record.values(yield* readSnapshots(out));
396
+ if (entries.length === 0) return undefined;
397
+ const referenced = new Set(entries.flatMap((entry) => entry.snapshot.prevIds ?? []));
398
+ const head = entries.find(
399
+ (entry) => entry.snapshot.id !== undefined && !referenced.has(entry.snapshot.id),
400
+ );
401
+ // Fall back to name order for snapshots without id/prevIds chains.
402
+ const latest = head ?? entries[entries.length - 1]!;
403
+ return { snapshot: latest.snapshot, hash: sha(latest.text) };
404
+ });
331
405
 
332
406
  /**
333
- * Run drizzle-kit's diff against the latest stored snapshot and
334
- * return whether any SQL statements would be emitted. Used by both
335
- * `diff` (to decide whether the resource actually needs an update)
336
- * and `regenerate` (to decide whether to write a new migration dir).
407
+ * Bring a migrations folder up to drizzle-kit's current snapshot format
408
+ * with `drizzle-kit up`. Returns whether anything changed.
409
+ *
410
+ * Earlier Alchemy versions wrote every postgres snapshot as a full
411
+ * version-9 snapshot without a `type`. That is the version-8 shape, which
412
+ * `up` turns into drizzle-kit's base/diff chain, so relabel them first.
337
413
  */
338
- const detectDrift = (props: SchemaProps) =>
339
- Effect.gen(function* () {
340
- const out = resolveOut(props);
341
- const dialect = props.dialect ?? "postgres";
342
-
343
- const kit = yield* loadKit(dialect);
344
- if (!kit.generateDrizzleJson || !kit.generateMigration) {
345
- return yield* detectDriftWithCli(props);
414
+ const upgradeFolder = Effect.fn("Drizzle.upgradeFolder")(function* (
415
+ props: SchemaProps,
416
+ out: string,
417
+ ) {
418
+ if (!(yield* fs.exists(out))) return false;
419
+ if (cliDialect(props) === "postgresql") {
420
+ for (const [dir, { snapshot }] of Record.toEntries(yield* readSnapshots(out))) {
421
+ if (snapshot.type !== undefined || snapshot.version !== "9") continue;
422
+ yield* fs.writeFileString(
423
+ path.join(out, dir, "snapshot.json"),
424
+ JSON.stringify({ ...snapshot, version: "8", renames: [] }, null, 2),
425
+ );
346
426
  }
347
-
348
- const generateDrizzleJson = kit.generateDrizzleJson;
349
- const generateMigration = kit.generateMigration;
350
-
351
- const schemaModule = yield* loadSchemaModule(props);
352
- const prevEntry = yield* readLatestSnapshot(out);
353
- const cur = yield* Effect.tryPromise({
354
- try: () => generateDrizzleJson(schemaModule, prevEntry?.snapshot.id),
355
- catch: (cause) => new Error(`drizzle-kit generateDrizzleJson failed: ${cause}`),
356
- });
357
-
358
- // For the initial migration, drizzle-kit needs an *empty* snapshot
359
- // produced by `generateDrizzleJson({})`, not a bare `{}` — the
360
- // snapshot has internal fields (`ddl`, etc.) that the differ reads.
361
- const prev =
362
- prevEntry?.snapshot ??
363
- (yield* Effect.tryPromise({
364
- try: () => generateDrizzleJson({}),
365
- catch: (cause) =>
366
- new Error(`drizzle-kit generateDrizzleJson (empty baseline) failed: ${cause}`),
367
- }));
368
- const sqlStatements = yield* Effect.tryPromise({
369
- try: () => generateMigration(prev, cur),
370
- catch: (cause) => new Error(`drizzle-kit generateMigration failed: ${cause}`),
371
- });
372
- return {
373
- mode: "programmatic" as const,
374
- out,
375
- cur,
376
- prevEntry,
377
- sqlStatements,
378
- };
379
- }).pipe(Artifacts.cached("detectDrift"));
427
+ }
428
+ const { upgraded } = yield* drizzleKit.up({ dialect: cliDialect(props), out });
429
+ return upgraded.length > 0;
430
+ });
431
+
432
+ // The schema the migrations folder describes. Postgres folders are diff
433
+ // chains drizzle-kit rebuilds; other dialects store full snapshots.
434
+ const readPreviousSnapshot = Effect.fn("Drizzle.readPreviousSnapshot")(function* (
435
+ kit: DrizzleKitPayload,
436
+ out: string,
437
+ ) {
438
+ const reconstruct = kit.reconstruct;
439
+ if (reconstruct === undefined) return (yield* readLatestSnapshot(out))?.snapshot;
440
+ const files = Record.map(yield* readSnapshots(out), ({ snapshot }) => snapshot);
441
+ if (Record.isEmptyRecord(files)) return undefined;
442
+ const parents = new Set(Record.values(files).flatMap((file) => file.prevIds ?? []));
443
+ const leaves = Record.values(files).filter(
444
+ ({ id }) => id !== undefined && !parents.has(id),
445
+ );
446
+ if (leaves.length < 2) {
447
+ return yield* callDrizzleKit("reconstruct", () => reconstruct(files));
448
+ }
449
+ // Unmerged branches: reconstruct an empty merge of their leaves, the
450
+ // combined state drizzle-kit's generate diffs against.
451
+ const merge = {
452
+ ...leaves[0]!,
453
+ id: MERGE_ID,
454
+ prevIds: leaves.map(({ id }) => id!),
455
+ deltas: [],
456
+ };
457
+ return yield* callDrizzleKit("reconstruct", () =>
458
+ reconstruct({ ...files, [`99999999999999_${MERGE_ID}`]: merge }, MERGE_ID),
459
+ );
460
+ });
380
461
 
381
462
  /**
382
- * Generate a new migration directory if the schema has drifted from
383
- * the latest snapshot. Returns the new state regardless.
463
+ * The previous and current schema, for working out which entities a
464
+ * rename-or-create question could have been renamed from.
384
465
  */
385
- const regenerate = (props: SchemaProps) =>
386
- Effect.gen(function* () {
387
- const drift = yield* detectDrift(props);
466
+ const loadRenameContext = Effect.fn("Drizzle.loadRenameContext")(function* (
467
+ props: SchemaProps,
468
+ out: string,
469
+ ) {
470
+ const dialect = props.dialect ?? "postgres";
471
+ const kit = yield* importModule<DrizzleKitPayload>(`drizzle-kit/payload/${dialect}`);
472
+ const schemaModule = yield* Effect.tryPromise({
473
+ try: () =>
474
+ import(/* @vite-ignore */ resolveSchema(props)) as Promise<
475
+ Record.ReadonlyRecord<string, unknown>
476
+ >,
477
+ catch: (cause) => new SchemaImportFailed({ schema: props.schema, cause }),
478
+ });
479
+ const cur = yield* callDrizzleKit("generateDrizzleJson", () =>
480
+ kit.generateDrizzleJson(schemaModule),
481
+ );
482
+ const prev = yield* readPreviousSnapshot(kit, out);
483
+ return { prev: prev?.ddl ?? [], cur: cur.ddl ?? [] };
484
+ });
388
485
 
389
- if (drift.mode === "cli") {
390
- if (drift.changed) {
391
- yield* runDrizzleGenerate(props, drift.out);
486
+ /**
487
+ * Dry-run `drizzle-kit generate` until it has no unanswered questions,
488
+ * asking the user each rename-or-create question it reports. The SQL is
489
+ * applied to the real database later in the same deploy, so ambiguous
490
+ * changes are never answered automatically.
491
+ */
492
+ const resolveHints = Effect.fn("Drizzle.resolveHints")(function* (
493
+ props: SchemaProps,
494
+ out: string,
495
+ ) {
496
+ const hints: Array<Hint> = [];
497
+ const context = yield* Effect.cached(loadRenameContext(props, out));
498
+ // `explain` dry-runs generate: it reports the pending migration
499
+ // (`ok`), `no_changes`, or the questions it still needs answered
500
+ // (`missing_hints`) without writing anything. Answers can raise new
501
+ // questions (columns of a renamed table), so ask until none are left.
502
+ const result = yield* Effect.gen(function* () {
503
+ const result = yield* drizzleKit.generate({
504
+ ...props.generate,
505
+ dialect: cliDialect(props),
506
+ schema: schemaGlob(props),
507
+ out,
508
+ explain: true,
509
+ hints,
510
+ });
511
+ if (result.status === "missing_hints") {
512
+ const { prev, cur } = yield* context;
513
+ for (const hint of result.unresolved) {
514
+ hints.push(yield* askHint(props, hint, prev, cur, hints));
392
515
  }
393
-
394
- const migrations = yield* listMigrationDirs(drift.out);
395
- const latest = yield* readLatestSnapshot(drift.out);
396
- const snapshotHash = latest?.hash ?? sha(JSON.stringify(drift.cur));
397
- return {
398
- out: relativeOut(drift.out),
399
- snapshotHash,
400
- migrations,
401
- };
402
516
  }
517
+ return result;
518
+ }).pipe(Effect.repeat({ until: (result) => result.status !== "missing_hints" }));
519
+ return { hints, changed: result.status === "ok" };
520
+ });
403
521
 
404
- const { out, cur, sqlStatements } = drift;
522
+ /**
523
+ * Decide what a deploy would change, without touching `out`: upgrade a
524
+ * scratch copy of the folder, then resolve every question drizzle-kit
525
+ * has about the schema change. Cached so `diff` and `reconcile` share
526
+ * one set of answers.
527
+ */
528
+ const detectDrift = Effect.fn("Drizzle.detectDrift")(
529
+ function* (props: SchemaProps) {
530
+ const out = resolveOut(props);
531
+ const scratch = yield* fs.makeTempDirectoryScoped({ prefix: "alchemy-drizzle-" });
532
+ const migrations = path.join(scratch, "migrations");
533
+ if (yield* fs.exists(out)) yield* fs.copy(out, migrations);
534
+ const upgraded = yield* upgradeFolder(props, migrations);
535
+ const { hints, changed } = yield* resolveHints(props, migrations);
536
+ return { out, upgraded, hints, changed };
537
+ },
538
+ // Keyed by props: an interrupted create's recovery `read` sees the
539
+ // persisted props, which may differ from the ones being deployed.
540
+ (effect, props) =>
541
+ effect.pipe(
542
+ Effect.scoped,
543
+ Artifacts.cached(
544
+ `detectDrift:${cliDialect(props)}:${resolveSchema(props)}:${resolveOut(props)}:${JSON.stringify(props.generate ?? {})}`,
545
+ ),
546
+ ),
547
+ );
405
548
 
406
- if (sqlStatements.length > 0) {
407
- yield* fs.makeDirectory(out, { recursive: true });
408
- const dirName = `${tsStamp()}_migration`;
409
- const dirPath = path.join(out, dirName);
410
- yield* fs.makeDirectory(dirPath, { recursive: true });
411
- const sql = sqlStatements.join("\n--> statement-breakpoint\n") + "\n";
412
- yield* fs.writeFileString(path.join(dirPath, "migration.sql"), sql);
413
- yield* fs.writeFileString(
414
- path.join(dirPath, "snapshot.json"),
415
- JSON.stringify(cur, null, 2),
416
- );
549
+ /**
550
+ * Upgrade `out` if needed and generate the pending migration with the
551
+ * answers gathered by {@link detectDrift}.
552
+ */
553
+ const regenerate = Effect.fn("Drizzle.regenerate")(function* (props: SchemaProps) {
554
+ const { out, upgraded, hints, changed } = yield* detectDrift(props);
555
+ if (upgraded) yield* upgradeFolder(props, out);
556
+ if (changed) {
557
+ yield* fs.makeDirectory(out, { recursive: true });
558
+ const result = yield* drizzleKit.generate({
559
+ ...props.generate,
560
+ dialect: cliDialect(props),
561
+ schema: schemaGlob(props),
562
+ out,
563
+ hints,
564
+ });
565
+ if (result.status === "missing_hints") {
566
+ return yield* new UndecidedSchemaChange({ props, hint: result.unresolved[0]! });
417
567
  }
418
-
419
- const migrations = yield* listMigrationDirs(out);
420
- const latest = yield* readLatestSnapshot(out);
421
- const snapshotHash = latest?.hash ?? sha(JSON.stringify(cur));
422
- return {
423
- out: relativeOut(out),
424
- snapshotHash,
425
- migrations,
426
- };
427
- });
568
+ }
569
+ const latest = yield* readLatestSnapshot(out);
570
+ return {
571
+ out: relativeOut(out),
572
+ snapshotHash: latest?.hash ?? sha(""),
573
+ migrations: yield* listMigrationDirs(out),
574
+ };
575
+ });
428
576
 
429
577
  return {
430
578
  // Non-listable: a Drizzle.Schema is a local build artifact (generated
@@ -434,13 +582,17 @@ export const SchemaProvider = () =>
434
582
  list: () => Effect.succeed([]),
435
583
  diff: Effect.fn(function* ({ news, output }) {
436
584
  if (!isResolved(news)) return undefined;
437
- if (!output) return undefined;
438
- // Only flag an update when drizzle-kit would emit new SQL —
439
- // otherwise downstream resources (e.g. Neon.Branch) would see
440
- // `schema.out` as an unresolved Output during plan and cascade
441
- // into spurious updates of their own.
442
- const drift = yield* detectDrift(news);
443
- const changed = drift.mode === "cli" ? drift.changed : drift.sqlStatements.length > 0;
585
+ if (!output) {
586
+ // Re-driving an interrupted create: answer questions now, not
587
+ // during apply.
588
+ yield* detectDrift(news);
589
+ return undefined;
590
+ }
591
+ // Only flag an update when drizzle-kit would emit new SQL (or the
592
+ // folder needs upgrading) — otherwise downstream resources (e.g.
593
+ // Neon.Branch) would see `schema.out` as an unresolved Output during
594
+ // plan and cascade into spurious updates of their own.
595
+ const { changed, upgraded } = yield* detectDrift(news);
444
596
  // Originally `output.out` was an absolute path, which is not portable.
445
597
  // So, we trigger an update to migrate existing resources to the
446
598
  // canonical (cwd-relative) form. This is safe because `regenerate`
@@ -448,12 +600,18 @@ export const SchemaProvider = () =>
448
600
  // testing `isAbsolute`: on Windows a cross-drive `out` can never be
449
601
  // relativized, so its canonical form IS absolute and an isAbsolute
450
602
  // check would flag an update on every deploy.
451
- return changed || output.out !== relativeOut(resolveOut(news))
603
+ return changed || upgraded || output.out !== relativeOut(resolveOut(news))
452
604
  ? { action: "update" }
453
605
  : undefined;
454
606
  }),
455
607
  read: Effect.fn(function* ({ olds, output }) {
456
- if (!output) return undefined;
608
+ if (!output) {
609
+ // A create never reaches `diff`; plan only calls `read` to look
610
+ // for something to adopt. Ask any rename questions now so they
611
+ // are answered before apply starts changing other resources.
612
+ if (olds && isResolved(olds)) yield* detectDrift(olds);
613
+ return undefined;
614
+ }
457
615
  const out = resolveOut(olds ?? ({} as SchemaProps));
458
616
  const exists = yield* fs.exists(out);
459
617
  if (!exists) return undefined;
@@ -479,26 +637,170 @@ export const SchemaProvider = () =>
479
637
  }),
480
638
  );
481
639
 
482
- const copyDirectory = (
483
- from: string,
484
- to: string,
485
- ): Effect.Effect<void, PlatformError, Path.Path | FileSystem.FileSystem> =>
486
- Effect.gen(function* () {
487
- const path = yield* Path.Path;
488
- const fs = yield* FileSystem.FileSystem;
489
-
490
- yield* fs.makeDirectory(to, { recursive: true });
491
- const entries = yield* fs.readDirectory(from);
492
-
493
- for (const entry of entries) {
494
- const source = path.join(from, entry);
495
- const target = path.join(to, entry);
496
- const stat = yield* fs.stat(source);
497
- if (stat.type === "Directory") {
498
- yield* copyDirectory(source, target);
499
- } else {
500
- const contents = yield* fs.readFile(source);
501
- yield* fs.writeFile(target, contents);
502
- }
503
- }
504
- });
640
+ // drizzle-kit hint kinds → snapshot `ddl` entity types.
641
+ const ddlEntityTypes: Record.ReadonlyRecord<string, string> = {
642
+ table: "tables",
643
+ column: "columns",
644
+ index: "indexes",
645
+ enum: "enums",
646
+ sequence: "sequences",
647
+ view: "views",
648
+ schema: "schemas",
649
+ policy: "policies",
650
+ role: "roles",
651
+ check: "checks",
652
+ unique: "uniques",
653
+ primary_key: "pks",
654
+ "foreign key": "fks",
655
+ };
656
+
657
+ const MERGE_ID = "alchemy-unmerged-branches";
658
+
659
+ // mysql and sqlite have no schemas; drizzle-kit fills ids with `public`.
660
+ const DEFAULT_SCHEMA = "public";
661
+
662
+ const formatEntity = (entity: ReadonlyArray<string>) =>
663
+ (entity.length > 1 && entity[0] === DEFAULT_SCHEMA ? entity.slice(1) : entity).join(".");
664
+
665
+ type Located = { entityType: string; schema: string; table: string; name: string };
666
+
667
+ const locate = (row: DdlEntry) => ({
668
+ entityType: row.entityType,
669
+ schema: row.schema ?? DEFAULT_SCHEMA,
670
+ table: row.table ?? "",
671
+ name: row.name ?? "",
672
+ });
673
+
674
+ const idOf = (row: Located, arity: number) =>
675
+ Match.value(arity).pipe(
676
+ Match.when(1, () => [row.name]),
677
+ Match.when(2, () => [row.schema, row.name]),
678
+ Match.orElse(() => [row.schema, row.table, row.name]),
679
+ );
680
+
681
+ type Rename = Extract<Hint, { type: "rename" }>;
682
+
683
+ // Give `row` the id `to`, shaped like the `from` id it was matched by.
684
+ const withId = (row: Located, to: ReadonlyArray<string>) =>
685
+ Match.value(to.length).pipe(
686
+ Match.when(1, () => ({ ...row, name: to[0]! })),
687
+ Match.when(2, () => ({ ...row, schema: to[0]!, name: to[1]! })),
688
+ Match.orElse(() => ({ ...row, schema: to[0]!, table: to[1]!, name: to[2]! })),
689
+ );
690
+
691
+ const renameRow = (row: Located, rename: Rename) => {
692
+ const { from, to } = rename;
693
+ return Match.value(rename).pipe(
694
+ // Schema rows name themselves; everything else names its schema.
695
+ Match.when({ kind: "schema" }, () =>
696
+ Match.value(row).pipe(
697
+ Match.when({ entityType: "schemas", name: from[0] }, () => ({ ...row, name: to[0]! })),
698
+ Match.when({ entityType: (type) => type !== "schemas", schema: from[0] }, () => ({
699
+ ...row,
700
+ schema: to[0]!,
701
+ })),
702
+ Match.orElse(() => row),
703
+ ),
704
+ ),
705
+ // The table and everything inside it follow the rename (or move).
706
+ Match.when({ kind: "table" }, () =>
707
+ Match.value(row).pipe(
708
+ Match.when({ entityType: "tables", schema: from[0], name: from[1] }, () => withId(row, to)),
709
+ Match.when({ schema: from[0], table: from[1] }, () => ({
710
+ ...row,
711
+ schema: to[0]!,
712
+ table: to[1]!,
713
+ })),
714
+ Match.orElse(() => row),
715
+ ),
716
+ ),
717
+ Match.orElse(({ kind }) =>
718
+ row.entityType === ddlEntityTypes[kind] &&
719
+ JSON.stringify(idOf(row, from.length)) === JSON.stringify(from)
720
+ ? withId(row, to)
721
+ : row,
722
+ ),
723
+ );
724
+ };
725
+
726
+ /**
727
+ * Carry previous entities through the renames answered so far, so a later
728
+ * question about the renamed table (or a table moved to another schema)
729
+ * matches the entities that moved with it.
730
+ */
731
+ const applyRenames = (rows: ReadonlyArray<Located>, hints: ReadonlyArray<Hint>) => {
732
+ let current = rows;
733
+ for (const hint of hints) {
734
+ if (hint.type === "rename") current = current.map((row) => renameRow(row, hint));
735
+ }
736
+ return current;
737
+ };
738
+
739
+ /**
740
+ * Entities a rename-or-create question could have been renamed from: same
741
+ * kind, present before (after the renames answered so far), gone now.
742
+ * Columns, indexes, … are offered from the same table first.
743
+ */
744
+ const renameCandidates = (
745
+ hint: MissingHint,
746
+ prev: ReadonlyArray<DdlEntry>,
747
+ cur: ReadonlyArray<DdlEntry>,
748
+ hints: ReadonlyArray<Hint>,
749
+ ) => {
750
+ const entityType = ddlEntityTypes[hint.kind];
751
+ const arity = hint.entity.length;
752
+ const ofKind = (rows: ReadonlyArray<Located>) =>
753
+ rows.filter((row) => row.entityType === entityType);
754
+ const current = new Set(ofKind(cur.map(locate)).map((row) => JSON.stringify(idOf(row, arity))));
755
+ const gone = ofKind(applyRenames(prev.map(locate), hints))
756
+ .map((row) => idOf(row, arity))
757
+ .filter((id) => !current.has(JSON.stringify(id)));
758
+ const sameParent = gone.filter(
759
+ (id) => arity < 3 || (id[0] === hint.entity[0] && id[1] === hint.entity[1]),
760
+ );
761
+ return sameParent.length > 0 ? sameParent : gone;
762
+ };
763
+
764
+ /**
765
+ * Answer one drizzle-kit `MissingHint` with an Alchemy prompt. drizzle-kit
766
+ * only asks when a rename is possible, so the question is always shown —
767
+ * never answered with "create" on the user's behalf. A dismissed prompt stays
768
+ * a `TerminalCancelled` so the CLI exits quietly, the same as cancelling the
769
+ * deploy confirmation.
770
+ */
771
+ const askHint = Effect.fn("Drizzle.askHint")(
772
+ function* (
773
+ props: SchemaProps,
774
+ hint: MissingHint,
775
+ prev: ReadonlyArray<DdlEntry>,
776
+ cur: ReadonlyArray<DdlEntry>,
777
+ hints: ReadonlyArray<Hint>,
778
+ ) {
779
+ const interaction = yield* Effect.serviceOption(Interaction);
780
+ if (Option.isNone(interaction)) return yield* new UndecidedSchemaChange({ props, hint });
781
+
782
+ const name = formatEntity(hint.entity);
783
+ return yield* interaction.value.prompt.select<Hint>({
784
+ message: `Is ${hint.kind} ${name} created or renamed from another ${hint.kind}?`,
785
+ options: [
786
+ {
787
+ value: { type: "create", kind: hint.kind, entity: hint.entity },
788
+ label: `+ ${name}`,
789
+ description: `create ${hint.kind}`,
790
+ },
791
+ ...renameCandidates(hint, prev, cur, hints).map((from) => ({
792
+ value: { type: "rename", kind: hint.kind, from, to: hint.entity } satisfies Hint,
793
+ label: `~ ${formatEntity(from)} › ${name}`,
794
+ description: `rename ${hint.kind}`,
795
+ })),
796
+ ],
797
+ descriptionPlacement: "inline",
798
+ });
799
+ },
800
+ (effect, props, hint) =>
801
+ effect.pipe(
802
+ Effect.catchTag("NonInteractiveTerminal", () =>
803
+ Effect.fail(new UndecidedSchemaChange({ props, hint })),
804
+ ),
805
+ ),
806
+ );