@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/lib/Schema.js CHANGED
@@ -1,5 +1,14 @@
1
- var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx, extraExtensions, ignoreCase) {
2
2
  if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ if (extraExtensions) {
4
+ for (var extension in extraExtensions) {
5
+ var suffix = path.slice(-extension.length);
6
+ if (suffix === extension || ignoreCase && suffix.toLowerCase() === extension.toLowerCase()) {
7
+ var output = extraExtensions[extension];
8
+ return output === extension ? path : path.slice(0, -extension.length) + output;
9
+ }
10
+ }
11
+ }
3
12
  return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
13
  return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
14
  });
@@ -9,20 +18,84 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
9
18
  import * as crypto from "node:crypto";
10
19
  import * as Artifacts from "alchemy/Artifacts";
11
20
  import { isResolved } from "alchemy/Diff";
21
+ import { Interaction } from "alchemy/Interaction";
12
22
  import * as Provider from "alchemy/Provider";
13
23
  import { Resource } from "alchemy/Resource";
14
- import { exec } from "alchemy/Util/exec";
15
- import { isNonInteractive } from "alchemy/Util/interactive";
24
+ import * as Data from "effect/Data";
16
25
  import * as Effect from "effect/Effect";
17
26
  import * as FileSystem from "effect/FileSystem";
27
+ import * as Match from "effect/Match";
28
+ import * as Option from "effect/Option";
18
29
  import * as Path from "effect/Path";
19
- import { ChildProcess } from "effect/process";
30
+ import * as Record from "effect/Record";
31
+ import * as S from "effect/Schema";
32
+ import * as Semaphore from "effect/Semaphore";
33
+ // Unlisted fields are kept (the rest records), so a decoded snapshot can be
34
+ // written back whole.
35
+ const DdlEntry = S.StructWithRest(S.Struct({
36
+ entityType: S.String,
37
+ schema: S.optional(S.String),
38
+ table: S.optional(S.String),
39
+ name: S.optional(S.String),
40
+ }), [S.Record(S.String, S.Unknown)]);
41
+ // A `snapshot.json`. Postgres folders hold one `base` snapshot followed by
42
+ // `diff` snapshots carrying `deltas`; mysql and sqlite snapshots are full.
43
+ const Snapshot = S.StructWithRest(S.Struct({
44
+ type: S.optional(S.Literals(["base", "diff"])),
45
+ version: S.optional(S.String),
46
+ dialect: S.optional(S.String),
47
+ id: S.optional(S.String),
48
+ prevIds: S.String.pipe(S.Array, S.optional),
49
+ ddl: DdlEntry.pipe(S.Array, S.optional),
50
+ }), [S.Record(S.String, S.Unknown)]);
51
+ const decodeSnapshot = S.decodeEffect(S.fromJsonString(Snapshot));
52
+ class DrizzleKitLoadFailed extends Data.TaggedError("DrizzleKitLoadFailed") {
53
+ get message() {
54
+ return `Failed to load ${this.module} (is drizzle-kit installed?): ${this.cause instanceof Error ? this.cause.message : String(this.cause)}`;
55
+ }
56
+ }
57
+ class InvalidSnapshot extends Data.TaggedError("InvalidSnapshot") {
58
+ get message() {
59
+ return `Invalid drizzle snapshot at ${this.file}: ${this.cause.message}`;
60
+ }
61
+ }
62
+ class SchemaImportFailed extends Data.TaggedError("SchemaImportFailed") {
63
+ get message() {
64
+ return `Failed to import schema at ${this.schema}: ${this.cause instanceof Error ? this.cause.message : String(this.cause)}`;
65
+ }
66
+ }
67
+ class DrizzleKitFailed extends Data.TaggedError("DrizzleKitFailed") {
68
+ get message() {
69
+ // Thrown errors carry their own message; reported ones (`{ status:
70
+ // "error", error }`) are plain objects like `{ code, kind, snapshot }`.
71
+ const { cause } = this;
72
+ const detail = cause instanceof Error ? cause.message : JSON.stringify(cause);
73
+ return `drizzle-kit ${this.command} failed: ${detail}`;
74
+ }
75
+ }
76
+ class DrizzleKitExited extends Data.TaggedError("DrizzleKitExited") {
77
+ get message() {
78
+ return `drizzle-kit ${this.command} found errors in the schema (such as two columns with one name) and tried to exit the process`;
79
+ }
80
+ }
81
+ /** An ambiguous schema change with no terminal to ask. */
82
+ class UndecidedSchemaChange extends Data.TaggedError("UndecidedSchemaChange") {
83
+ get message() {
84
+ const { props, hint } = this;
85
+ return [
86
+ `${props.schema} has an ambiguous change that needs a decision:`,
87
+ ` ${formatEntity(hint.entity)}: created or renamed?`,
88
+ "Re-run in an interactive terminal to answer it.",
89
+ ].join("\n");
90
+ }
91
+ }
20
92
  /**
21
93
  * A Drizzle schema managed as an Alchemy resource.
22
94
  *
23
- * Wraps drizzle-kit's programmatic API (`generateDrizzleJson` /
24
- * `generateMigration`) so migration SQL is regenerated as part of `alchemy
25
- * deploy` whenever the source schema changes. The output directory is
95
+ * Runs `drizzle-kit generate` through drizzle-kit's programmatic API so
96
+ * migration SQL is regenerated as part of `alchemy deploy` whenever the
97
+ * source schema changes. Ambiguous changes (a rename or a new column/table?)
98
+ * are asked with Alchemy's prompts during plan. The output directory is
26
99
  * intended to be passed straight to a database resource's `migrations`
27
100
  * prop, giving you a single deploy-driven flow:
28
101
  *
@@ -53,18 +126,54 @@ import { ChildProcess } from "effect/process";
53
126
  * @resource
54
127
  */
55
128
  export const Schema = Resource("Drizzle.Schema");
56
- const dialectModule = (dialect) => {
57
- switch (dialect) {
58
- case "postgres":
59
- return "drizzle-kit/api-postgres";
60
- case "mysql":
61
- return "drizzle-kit/api-mysql";
62
- case "sqlite":
63
- return "drizzle-kit/api-sqlite";
129
+ const cliDialect = (props) => props.dialect === undefined || props.dialect === "postgres" ? "postgresql" : props.dialect;
130
+ const sha = (s) => crypto.createHash("sha256").update(s).digest("hex");
131
+ const importModule = (module) => Effect.tryPromise({
132
+ try: () => import(__rewriteRelativeImportExtension(/* @vite-ignore */ module)),
133
+ catch: (cause) => new DrizzleKitLoadFailed({ module, cause }),
134
+ });
135
+ // drizzle-kit runs in-process but calls `process.exit` on schema errors.
136
+ // Calls trap the exit and fail instead, one at a time since the trap swaps
137
+ // the global.
138
+ const exitTrap = Semaphore.makeUnsafe(1);
139
+ const callDrizzleKit = (command, run) => Effect.acquireUseRelease(Effect.sync(() => {
140
+ // Saved only to restore `process.exit` verbatim, never called unbound.
141
+ // Read through `Reflect.get`: whether `typescript/unbound-method`
142
+ // flags `process.exit` depends on which `process` typing the linter's
143
+ // program resolves, which unrelated files change.
144
+ const trap = { exit: Reflect.get(process, "exit"), exited: false };
145
+ process.exit = () => {
146
+ trap.exited = true;
147
+ throw new Error("drizzle-kit exited");
148
+ };
149
+ return trap;
150
+ }), (trap) =>
151
+ // `cli` commands catch the trap's throw and report it as an error result.
152
+ // Uninterruptible: the promise can't be cancelled, so the trap stays
153
+ // until it settles.
154
+ Effect.tryPromise({
155
+ try: run,
156
+ catch: (cause) => trap.exited
157
+ ? new DrizzleKitExited({ command })
158
+ : new DrizzleKitFailed({ command, cause }),
159
+ }).pipe(Effect.flatMap((result) => trap.exited ? Effect.fail(new DrizzleKitExited({ command })) : Effect.succeed(result)), Effect.uninterruptible), (trap) => Effect.sync(() => {
160
+ process.exit = trap.exit;
161
+ })).pipe((effect) => exitTrap.withPermit(effect));
162
+ // A `drizzle-kit/cli` command as an Effect, failing on a thrown or reported error.
163
+ const command = (name) => Effect.fn(`drizzleKit.${name}`)(function* (options) {
164
+ const cli = yield* importModule("drizzle-kit/cli");
165
+ const result = yield* callDrizzleKit(name, () => cli[name](options));
166
+ if (result.status === "error") {
167
+ return yield* new DrizzleKitFailed({ command: name, cause: result.error });
64
168
  }
169
+ return result;
170
+ });
171
+ const drizzleKit = {
172
+ /** `drizzle-kit generate`; with `explain` a dry run that writes nothing. */
173
+ generate: command("generate"),
174
+ /** `drizzle-kit up`: upgrade snapshots to the current format. */
175
+ up: command("up"),
65
176
  };
66
- const sha = (s) => crypto.createHash("sha256").update(s).digest("hex");
67
- const tsStamp = () => new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14);
68
177
  export const SchemaProvider = () => Provider.effect(Schema, Effect.gen(function* () {
69
178
  const fs = yield* FileSystem.FileSystem;
70
179
  const path = yield* Path.Path;
@@ -75,227 +184,178 @@ export const SchemaProvider = () => Provider.effect(Schema, Effect.gen(function*
75
184
  // `resolveOut` form.
76
185
  const relativeOut = (abs) => path.relative(process.cwd(), abs);
77
186
  const resolveSchema = (p) => path.resolve(process.cwd(), p.schema);
78
- const loadSchemaModule = (p) => Effect.gen(function* () {
79
- const schemaPath = resolveSchema(p);
80
- return yield* Effect.tryPromise({
81
- try: () => import(__rewriteRelativeImportExtension(/* @vite-ignore */ schemaPath)),
82
- catch: (cause) => new Error(`Failed to import schema at ${p.schema}: ${cause}`),
83
- });
84
- });
85
- const loadKit = (dialect) => Effect.tryPromise({
86
- try: () => import(__rewriteRelativeImportExtension(/* @vite-ignore */ dialectModule(dialect))),
87
- catch: (cause) => new Error(`Failed to load drizzle-kit/${dialect} (is drizzle-kit installed?): ${cause}`),
88
- });
89
- const drizzleKitBin = (dialect) => Effect.gen(function* () {
90
- const apiUrl = yield* Effect.try({
91
- try: () => import.meta.resolve(dialectModule(dialect)),
92
- catch: (cause) => new Error(`Failed to resolve drizzle-kit/${dialect}: ${cause}`),
93
- });
94
- const apiFileUrl = yield* Effect.try({
95
- try: () => new URL(apiUrl),
96
- catch: (cause) => new Error(`Failed to parse drizzle-kit/${dialect} URL: ${cause}`),
97
- });
98
- const apiPath = yield* path.fromFileUrl(apiFileUrl);
99
- return path.join(path.dirname(apiPath), "bin.cjs");
100
- });
101
- const runDrizzleGenerate = (props, out) => Effect.gen(function* () {
102
- const dialect = props.dialect ?? "postgres";
103
- const bin = yield* drizzleKitBin(dialect);
104
- const schemaPath = resolveSchema(props);
105
- const nodeExecPath = yield* Effect.sync(() => process.execPath);
106
- const nodeModulesPath = path.join(process.cwd(), "node_modules");
107
- // drizzle-kit globs the --schema/--out values; Windows backslashes
108
- // are treated as glob escapes and match nothing, so pass forward
109
- // slashes.
110
- const args = [
111
- bin,
112
- "generate",
113
- "--dialect",
114
- dialect === "postgres" ? "postgresql" : dialect,
115
- "--schema",
116
- schemaPath.replaceAll("\\", "/"),
117
- "--out",
118
- out.replaceAll("\\", "/"),
119
- ];
120
- const commandOptions = {
121
- cwd: process.cwd(),
122
- env: { NODE_PATH: nodeModulesPath },
123
- extendEnv: true,
124
- };
125
- // Hand the terminal to drizzle-kit only when this process may
126
- // prompt at all — the same detection (TTY, CI, `--no-input`,
127
- // `ALCHEMY_NO_TUI`, agent env) every other spawned command uses.
128
- if (!isNonInteractive() && process.stderr.isTTY) {
129
- const handle = yield* ChildProcess.make(nodeExecPath, args, {
130
- ...commandOptions,
131
- stdin: "inherit",
132
- stdout: "inherit",
133
- stderr: "inherit",
134
- }).pipe(Effect.mapError((cause) => new Error(`drizzle-kit generate failed: ${String(cause)}`)));
135
- const exitCode = yield* handle.exitCode;
136
- if (exitCode !== 0) {
137
- return yield* Effect.fail(new Error(`drizzle-kit generate failed with exit ${exitCode}`));
138
- }
139
- return;
140
- }
141
- const result = yield* exec(ChildProcess.make(nodeExecPath, args, commandOptions)).pipe(Effect.mapError((cause) => new Error(`drizzle-kit generate failed: ${String(cause)}`)));
142
- if (result.exitCode === 0)
143
- return;
144
- // Since drizzle-kit 1.0.0-rc.4 the non-interactive CLI refuses
145
- // ambiguous decisions (rename-vs-create, data-loss confirmation)
146
- // with a `missing_hints` report on exit 2. These are deliberate
147
- // safety prompts — the generated SQL is applied to the real
148
- // database later in the same deploy — so we never answer them
149
- // automatically. Ask the user to decide.
150
- if (result.exitCode === 2) {
151
- return yield* Effect.fail(new Error([
152
- `drizzle-kit needs a decision for ${props.schema} that cannot be made non-interactively (rename vs create, or a change that loses data):`,
153
- "",
154
- result.stdout.trim(),
155
- "",
156
- "To resolve, generate the migration yourself and commit it:",
157
- ` npx drizzle-kit generate --dialect ${dialect === "postgres" ? "postgresql" : dialect} --schema ${props.schema} --out ${props.out ?? "./migrations"}`,
158
- "then re-run the deploy (the schema resource will see no drift and apply the committed migration).",
159
- "Alternatively, run the deploy in a terminal to answer drizzle-kit's prompts interactively.",
160
- ].join("\n")));
161
- }
162
- return yield* Effect.fail(new Error(`drizzle-kit generate failed: ${result.stdout}\n${result.stderr}`));
163
- });
187
+ // drizzle-kit reads `schema` as a glob, where `\` is an escape.
188
+ const schemaGlob = (p) => resolveSchema(p).replaceAll("\\", "/");
164
189
  // List `<ts>_*` migration directories under `out`, sorted by numeric
165
190
  // prefix. Returns an empty array if `out` doesn't exist.
166
- const listMigrationDirs = (out) => Effect.gen(function* () {
167
- const exists = yield* fs.exists(out);
168
- if (!exists)
191
+ const listMigrationDirs = Effect.fn("Drizzle.listMigrationDirs")(function* (out) {
192
+ if (!(yield* fs.exists(out)))
169
193
  return [];
170
194
  const entries = yield* fs.readDirectory(out);
171
195
  return entries.filter((name) => /^\d+_/.test(name)).sort();
172
196
  });
197
+ const readSnapshots = Effect.fn("Drizzle.readSnapshots")(function* (out) {
198
+ const entries = yield* Effect.forEach(yield* listMigrationDirs(out), (dir) => Effect.gen(function* () {
199
+ const file = path.join(out, dir, "snapshot.json");
200
+ if (!(yield* fs.exists(file)))
201
+ return [];
202
+ const text = yield* fs.readFileString(file);
203
+ const snapshot = yield* decodeSnapshot(text).pipe(Effect.mapError((cause) => new InvalidSnapshot({ file, cause })));
204
+ return [[dir, { snapshot, text }]];
205
+ }));
206
+ return Record.fromEntries(entries.flat());
207
+ });
173
208
  // The latest snapshot is the head of the `prevIds` chain — the one no
174
209
  // other snapshot points back to. Directory-name order alone is not
175
210
  // enough: two migrations generated within the same second share the
176
211
  // timestamp prefix, and the random name suffix then decides the sort.
177
- const readLatestSnapshot = (out) => Effect.gen(function* () {
178
- const dirs = yield* listMigrationDirs(out);
179
- const entries = [];
180
- for (const dir of dirs) {
181
- const snapshotPath = path.join(out, dir, "snapshot.json");
182
- const exists = yield* fs.exists(snapshotPath);
183
- if (!exists)
184
- continue;
185
- const text = yield* fs.readFileString(snapshotPath);
186
- entries.push({
187
- snapshot: JSON.parse(text),
188
- hash: sha(text),
189
- });
190
- }
212
+ const readLatestSnapshot = Effect.fn("Drizzle.readLatestSnapshot")(function* (out) {
213
+ const entries = Record.values(yield* readSnapshots(out));
191
214
  if (entries.length === 0)
192
215
  return undefined;
193
216
  const referenced = new Set(entries.flatMap((entry) => entry.snapshot.prevIds ?? []));
194
217
  const head = entries.find((entry) => entry.snapshot.id !== undefined && !referenced.has(entry.snapshot.id));
195
218
  // Fall back to name order for snapshots without id/prevIds chains.
196
- return head ?? entries[entries.length - 1];
219
+ const latest = head ?? entries[entries.length - 1];
220
+ return { snapshot: latest.snapshot, hash: sha(latest.text) };
197
221
  });
198
- const detectDriftWithCli = (props) => Effect.gen(function* () {
199
- const out = resolveOut(props);
200
- const tmpOut = yield* fs.makeTempDirectory({
201
- prefix: "alchemy-drizzle-generate-",
202
- });
203
- return yield* Effect.gen(function* () {
204
- const outExists = yield* fs.exists(out);
205
- if (outExists)
206
- yield* copyDirectory(out, tmpOut);
207
- const before = yield* listMigrationDirs(tmpOut);
208
- yield* runDrizzleGenerate(props, tmpOut);
209
- const after = yield* listMigrationDirs(tmpOut);
210
- const latest = yield* readLatestSnapshot(tmpOut);
211
- const changed = after.some((dir) => !before.includes(dir));
212
- const prevEntry = outExists ? yield* readLatestSnapshot(out) : undefined;
213
- return {
214
- mode: "cli",
215
- out,
216
- cur: latest?.snapshot,
217
- prevEntry,
218
- changed,
219
- };
220
- }).pipe(Effect.ensuring(fs.remove(tmpOut, { recursive: true }).pipe(Effect.ignore)));
222
+ /**
223
+ * Bring a migrations folder up to drizzle-kit's current snapshot format
224
+ * with `drizzle-kit up`. Returns whether anything changed.
225
+ *
226
+ * Earlier Alchemy versions wrote every postgres snapshot as a full
227
+ * version-9 snapshot without a `type`. That is the version-8 shape, which
228
+ * `up` turns into drizzle-kit's base/diff chain, so relabel them first.
229
+ */
230
+ const upgradeFolder = Effect.fn("Drizzle.upgradeFolder")(function* (props, out) {
231
+ if (!(yield* fs.exists(out)))
232
+ return false;
233
+ if (cliDialect(props) === "postgresql") {
234
+ for (const [dir, { snapshot }] of Record.toEntries(yield* readSnapshots(out))) {
235
+ if (snapshot.type !== undefined || snapshot.version !== "9")
236
+ continue;
237
+ yield* fs.writeFileString(path.join(out, dir, "snapshot.json"), JSON.stringify({ ...snapshot, version: "8", renames: [] }, null, 2));
238
+ }
239
+ }
240
+ const { upgraded } = yield* drizzleKit.up({ dialect: cliDialect(props), out });
241
+ return upgraded.length > 0;
242
+ });
243
+ // The schema the migrations folder describes. Postgres folders are diff
244
+ // chains drizzle-kit rebuilds; other dialects store full snapshots.
245
+ const readPreviousSnapshot = Effect.fn("Drizzle.readPreviousSnapshot")(function* (kit, out) {
246
+ const reconstruct = kit.reconstruct;
247
+ if (reconstruct === undefined)
248
+ return (yield* readLatestSnapshot(out))?.snapshot;
249
+ const files = Record.map(yield* readSnapshots(out), ({ snapshot }) => snapshot);
250
+ if (Record.isEmptyRecord(files))
251
+ return undefined;
252
+ const parents = new Set(Record.values(files).flatMap((file) => file.prevIds ?? []));
253
+ const leaves = Record.values(files).filter(({ id }) => id !== undefined && !parents.has(id));
254
+ if (leaves.length < 2) {
255
+ return yield* callDrizzleKit("reconstruct", () => reconstruct(files));
256
+ }
257
+ // Unmerged branches: reconstruct an empty merge of their leaves, the
258
+ // combined state drizzle-kit's generate diffs against.
259
+ const merge = {
260
+ ...leaves[0],
261
+ id: MERGE_ID,
262
+ prevIds: leaves.map(({ id }) => id),
263
+ deltas: [],
264
+ };
265
+ return yield* callDrizzleKit("reconstruct", () => reconstruct({ ...files, [`99999999999999_${MERGE_ID}`]: merge }, MERGE_ID));
221
266
  });
222
267
  /**
223
- * Run drizzle-kit's diff against the latest stored snapshot and
224
- * return whether any SQL statements would be emitted. Used by both
225
- * `diff` (to decide whether the resource actually needs an update)
226
- * and `regenerate` (to decide whether to write a new migration dir).
268
+ * The previous and current schema, for working out which entities a
269
+ * rename-or-create question could have been renamed from.
227
270
  */
228
- const detectDrift = (props) => Effect.gen(function* () {
229
- const out = resolveOut(props);
271
+ const loadRenameContext = Effect.fn("Drizzle.loadRenameContext")(function* (props, out) {
230
272
  const dialect = props.dialect ?? "postgres";
231
- const kit = yield* loadKit(dialect);
232
- if (!kit.generateDrizzleJson || !kit.generateMigration) {
233
- return yield* detectDriftWithCli(props);
234
- }
235
- const generateDrizzleJson = kit.generateDrizzleJson;
236
- const generateMigration = kit.generateMigration;
237
- const schemaModule = yield* loadSchemaModule(props);
238
- const prevEntry = yield* readLatestSnapshot(out);
239
- const cur = yield* Effect.tryPromise({
240
- try: () => generateDrizzleJson(schemaModule, prevEntry?.snapshot.id),
241
- catch: (cause) => new Error(`drizzle-kit generateDrizzleJson failed: ${cause}`),
273
+ const kit = yield* importModule(`drizzle-kit/payload/${dialect}`);
274
+ const schemaModule = yield* Effect.tryPromise({
275
+ try: () => import(__rewriteRelativeImportExtension(/* @vite-ignore */ resolveSchema(props))),
276
+ catch: (cause) => new SchemaImportFailed({ schema: props.schema, cause }),
242
277
  });
243
- // For the initial migration, drizzle-kit needs an *empty* snapshot
244
- // produced by `generateDrizzleJson({})`, not a bare `{}` — the
245
- // snapshot has internal fields (`ddl`, etc.) that the differ reads.
246
- const prev = prevEntry?.snapshot ??
247
- (yield* Effect.tryPromise({
248
- try: () => generateDrizzleJson({}),
249
- catch: (cause) => new Error(`drizzle-kit generateDrizzleJson (empty baseline) failed: ${cause}`),
250
- }));
251
- const sqlStatements = yield* Effect.tryPromise({
252
- try: () => generateMigration(prev, cur),
253
- catch: (cause) => new Error(`drizzle-kit generateMigration failed: ${cause}`),
254
- });
255
- return {
256
- mode: "programmatic",
257
- out,
258
- cur,
259
- prevEntry,
260
- sqlStatements,
261
- };
262
- }).pipe(Artifacts.cached("detectDrift"));
278
+ const cur = yield* callDrizzleKit("generateDrizzleJson", () => kit.generateDrizzleJson(schemaModule));
279
+ const prev = yield* readPreviousSnapshot(kit, out);
280
+ return { prev: prev?.ddl ?? [], cur: cur.ddl ?? [] };
281
+ });
263
282
  /**
264
- * Generate a new migration directory if the schema has drifted from
265
- * the latest snapshot. Returns the new state regardless.
283
+ * Dry-run `drizzle-kit generate` until it has no unanswered questions,
284
+ * asking the user each rename-or-create question it reports. The SQL is
285
+ * applied to the real database later in the same deploy, so ambiguous
286
+ * changes are never answered automatically.
266
287
  */
267
- const regenerate = (props) => Effect.gen(function* () {
268
- const drift = yield* detectDrift(props);
269
- if (drift.mode === "cli") {
270
- if (drift.changed) {
271
- yield* runDrizzleGenerate(props, drift.out);
288
+ const resolveHints = Effect.fn("Drizzle.resolveHints")(function* (props, out) {
289
+ const hints = [];
290
+ const context = yield* Effect.cached(loadRenameContext(props, out));
291
+ // `explain` dry-runs generate: it reports the pending migration
292
+ // (`ok`), `no_changes`, or the questions it still needs answered
293
+ // (`missing_hints`) without writing anything. Answers can raise new
294
+ // questions (columns of a renamed table), so ask until none are left.
295
+ const result = yield* Effect.gen(function* () {
296
+ const result = yield* drizzleKit.generate({
297
+ ...props.generate,
298
+ dialect: cliDialect(props),
299
+ schema: schemaGlob(props),
300
+ out,
301
+ explain: true,
302
+ hints,
303
+ });
304
+ if (result.status === "missing_hints") {
305
+ const { prev, cur } = yield* context;
306
+ for (const hint of result.unresolved) {
307
+ hints.push(yield* askHint(props, hint, prev, cur, hints));
308
+ }
272
309
  }
273
- const migrations = yield* listMigrationDirs(drift.out);
274
- const latest = yield* readLatestSnapshot(drift.out);
275
- const snapshotHash = latest?.hash ?? sha(JSON.stringify(drift.cur));
276
- return {
277
- out: relativeOut(drift.out),
278
- snapshotHash,
279
- migrations,
280
- };
281
- }
282
- const { out, cur, sqlStatements } = drift;
283
- if (sqlStatements.length > 0) {
310
+ return result;
311
+ }).pipe(Effect.repeat({ until: (result) => result.status !== "missing_hints" }));
312
+ return { hints, changed: result.status === "ok" };
313
+ });
314
+ /**
315
+ * Decide what a deploy would change, without touching `out`: upgrade a
316
+ * scratch copy of the folder, then resolve every question drizzle-kit
317
+ * has about the schema change. Cached so `diff` and `reconcile` share
318
+ * one set of answers.
319
+ */
320
+ const detectDrift = Effect.fn("Drizzle.detectDrift")(function* (props) {
321
+ const out = resolveOut(props);
322
+ const scratch = yield* fs.makeTempDirectoryScoped({ prefix: "alchemy-drizzle-" });
323
+ const migrations = path.join(scratch, "migrations");
324
+ if (yield* fs.exists(out))
325
+ yield* fs.copy(out, migrations);
326
+ const upgraded = yield* upgradeFolder(props, migrations);
327
+ const { hints, changed } = yield* resolveHints(props, migrations);
328
+ return { out, upgraded, hints, changed };
329
+ },
330
+ // Keyed by props: an interrupted create's recovery `read` sees the
331
+ // persisted props, which may differ from the ones being deployed.
332
+ (effect, props) => effect.pipe(Effect.scoped, Artifacts.cached(`detectDrift:${cliDialect(props)}:${resolveSchema(props)}:${resolveOut(props)}:${JSON.stringify(props.generate ?? {})}`)));
333
+ /**
334
+ * Upgrade `out` if needed and generate the pending migration with the
335
+ * answers gathered by {@link detectDrift}.
336
+ */
337
+ const regenerate = Effect.fn("Drizzle.regenerate")(function* (props) {
338
+ const { out, upgraded, hints, changed } = yield* detectDrift(props);
339
+ if (upgraded)
340
+ yield* upgradeFolder(props, out);
341
+ if (changed) {
284
342
  yield* fs.makeDirectory(out, { recursive: true });
285
- const dirName = `${tsStamp()}_migration`;
286
- const dirPath = path.join(out, dirName);
287
- yield* fs.makeDirectory(dirPath, { recursive: true });
288
- const sql = sqlStatements.join("\n--> statement-breakpoint\n") + "\n";
289
- yield* fs.writeFileString(path.join(dirPath, "migration.sql"), sql);
290
- yield* fs.writeFileString(path.join(dirPath, "snapshot.json"), JSON.stringify(cur, null, 2));
343
+ const result = yield* drizzleKit.generate({
344
+ ...props.generate,
345
+ dialect: cliDialect(props),
346
+ schema: schemaGlob(props),
347
+ out,
348
+ hints,
349
+ });
350
+ if (result.status === "missing_hints") {
351
+ return yield* new UndecidedSchemaChange({ props, hint: result.unresolved[0] });
352
+ }
291
353
  }
292
- const migrations = yield* listMigrationDirs(out);
293
354
  const latest = yield* readLatestSnapshot(out);
294
- const snapshotHash = latest?.hash ?? sha(JSON.stringify(cur));
295
355
  return {
296
356
  out: relativeOut(out),
297
- snapshotHash,
298
- migrations,
357
+ snapshotHash: latest?.hash ?? sha(""),
358
+ migrations: yield* listMigrationDirs(out),
299
359
  };
300
360
  });
301
361
  return {
@@ -307,14 +367,17 @@ export const SchemaProvider = () => Provider.effect(Schema, Effect.gen(function*
307
367
  diff: Effect.fn(function* ({ news, output }) {
308
368
  if (!isResolved(news))
309
369
  return undefined;
310
- if (!output)
370
+ if (!output) {
371
+ // Re-driving an interrupted create: answer questions now, not
372
+ // during apply.
373
+ yield* detectDrift(news);
311
374
  return undefined;
312
- // Only flag an update when drizzle-kit would emit new SQL —
313
- // otherwise downstream resources (e.g. Neon.Branch) would see
314
- // `schema.out` as an unresolved Output during plan and cascade
315
- // into spurious updates of their own.
316
- const drift = yield* detectDrift(news);
317
- const changed = drift.mode === "cli" ? drift.changed : drift.sqlStatements.length > 0;
375
+ }
376
+ // Only flag an update when drizzle-kit would emit new SQL (or the
377
+ // folder needs upgrading) — otherwise downstream resources (e.g.
378
+ // Neon.Branch) would see `schema.out` as an unresolved Output during
379
+ // plan and cascade into spurious updates of their own.
380
+ const { changed, upgraded } = yield* detectDrift(news);
318
381
  // Originally `output.out` was an absolute path, which is not portable.
319
382
  // So, we trigger an update to migrate existing resources to the
320
383
  // canonical (cwd-relative) form. This is safe because `regenerate`
@@ -322,13 +385,19 @@ export const SchemaProvider = () => Provider.effect(Schema, Effect.gen(function*
322
385
  // testing `isAbsolute`: on Windows a cross-drive `out` can never be
323
386
  // relativized, so its canonical form IS absolute and an isAbsolute
324
387
  // check would flag an update on every deploy.
325
- return changed || output.out !== relativeOut(resolveOut(news))
388
+ return changed || upgraded || output.out !== relativeOut(resolveOut(news))
326
389
  ? { action: "update" }
327
390
  : undefined;
328
391
  }),
329
392
  read: Effect.fn(function* ({ olds, output }) {
330
- if (!output)
393
+ if (!output) {
394
+ // A create never reaches `diff`; plan only calls `read` to look
395
+ // for something to adopt. Ask any rename questions now so they
396
+ // are answered before apply starts changing other resources.
397
+ if (olds && isResolved(olds))
398
+ yield* detectDrift(olds);
331
399
  return undefined;
400
+ }
332
401
  const out = resolveOut(olds ?? {});
333
402
  const exists = yield* fs.exists(out);
334
403
  if (!exists)
@@ -351,22 +420,109 @@ export const SchemaProvider = () => Provider.effect(Schema, Effect.gen(function*
351
420
  }),
352
421
  };
353
422
  }));
354
- const copyDirectory = (from, to) => Effect.gen(function* () {
355
- const path = yield* Path.Path;
356
- const fs = yield* FileSystem.FileSystem;
357
- yield* fs.makeDirectory(to, { recursive: true });
358
- const entries = yield* fs.readDirectory(from);
359
- for (const entry of entries) {
360
- const source = path.join(from, entry);
361
- const target = path.join(to, entry);
362
- const stat = yield* fs.stat(source);
363
- if (stat.type === "Directory") {
364
- yield* copyDirectory(source, target);
365
- }
366
- else {
367
- const contents = yield* fs.readFile(source);
368
- yield* fs.writeFile(target, contents);
369
- }
370
- }
423
+ // drizzle-kit hint kinds → snapshot `ddl` entity types.
424
+ const ddlEntityTypes = {
425
+ table: "tables",
426
+ column: "columns",
427
+ index: "indexes",
428
+ enum: "enums",
429
+ sequence: "sequences",
430
+ view: "views",
431
+ schema: "schemas",
432
+ policy: "policies",
433
+ role: "roles",
434
+ check: "checks",
435
+ unique: "uniques",
436
+ primary_key: "pks",
437
+ "foreign key": "fks",
438
+ };
439
+ const MERGE_ID = "alchemy-unmerged-branches";
440
+ // mysql and sqlite have no schemas; drizzle-kit fills ids with `public`.
441
+ const DEFAULT_SCHEMA = "public";
442
+ const formatEntity = (entity) => (entity.length > 1 && entity[0] === DEFAULT_SCHEMA ? entity.slice(1) : entity).join(".");
443
+ const locate = (row) => ({
444
+ entityType: row.entityType,
445
+ schema: row.schema ?? DEFAULT_SCHEMA,
446
+ table: row.table ?? "",
447
+ name: row.name ?? "",
371
448
  });
449
+ const idOf = (row, arity) => Match.value(arity).pipe(Match.when(1, () => [row.name]), Match.when(2, () => [row.schema, row.name]), Match.orElse(() => [row.schema, row.table, row.name]));
450
+ // Give `row` the id `to`, shaped like the `from` id it was matched by.
451
+ const withId = (row, to) => Match.value(to.length).pipe(Match.when(1, () => ({ ...row, name: to[0] })), Match.when(2, () => ({ ...row, schema: to[0], name: to[1] })), Match.orElse(() => ({ ...row, schema: to[0], table: to[1], name: to[2] })));
452
+ const renameRow = (row, rename) => {
453
+ const { from, to } = rename;
454
+ return Match.value(rename).pipe(
455
+ // Schema rows name themselves; everything else names its schema.
456
+ Match.when({ kind: "schema" }, () => Match.value(row).pipe(Match.when({ entityType: "schemas", name: from[0] }, () => ({ ...row, name: to[0] })), Match.when({ entityType: (type) => type !== "schemas", schema: from[0] }, () => ({
457
+ ...row,
458
+ schema: to[0],
459
+ })), Match.orElse(() => row))),
460
+ // The table and everything inside it follow the rename (or move).
461
+ Match.when({ kind: "table" }, () => Match.value(row).pipe(Match.when({ entityType: "tables", schema: from[0], name: from[1] }, () => withId(row, to)), Match.when({ schema: from[0], table: from[1] }, () => ({
462
+ ...row,
463
+ schema: to[0],
464
+ table: to[1],
465
+ })), Match.orElse(() => row))), Match.orElse(({ kind }) => row.entityType === ddlEntityTypes[kind] &&
466
+ JSON.stringify(idOf(row, from.length)) === JSON.stringify(from)
467
+ ? withId(row, to)
468
+ : row));
469
+ };
470
+ /**
471
+ * Carry previous entities through the renames answered so far, so a later
472
+ * question about the renamed table (or a table moved to another schema)
473
+ * matches the entities that moved with it.
474
+ */
475
+ const applyRenames = (rows, hints) => {
476
+ let current = rows;
477
+ for (const hint of hints) {
478
+ if (hint.type === "rename")
479
+ current = current.map((row) => renameRow(row, hint));
480
+ }
481
+ return current;
482
+ };
483
+ /**
484
+ * Entities a rename-or-create question could have been renamed from: same
485
+ * kind, present before (after the renames answered so far), gone now.
486
+ * Columns, indexes, … are offered from the same table first.
487
+ */
488
+ const renameCandidates = (hint, prev, cur, hints) => {
489
+ const entityType = ddlEntityTypes[hint.kind];
490
+ const arity = hint.entity.length;
491
+ const ofKind = (rows) => rows.filter((row) => row.entityType === entityType);
492
+ const current = new Set(ofKind(cur.map(locate)).map((row) => JSON.stringify(idOf(row, arity))));
493
+ const gone = ofKind(applyRenames(prev.map(locate), hints))
494
+ .map((row) => idOf(row, arity))
495
+ .filter((id) => !current.has(JSON.stringify(id)));
496
+ const sameParent = gone.filter((id) => arity < 3 || (id[0] === hint.entity[0] && id[1] === hint.entity[1]));
497
+ return sameParent.length > 0 ? sameParent : gone;
498
+ };
499
+ /**
500
+ * Answer one drizzle-kit `MissingHint` with an Alchemy prompt. drizzle-kit
501
+ * only asks when a rename is possible, so the question is always shown —
502
+ * never answered with "create" on the user's behalf. A dismissed prompt stays
503
+ * a `TerminalCancelled` so the CLI exits quietly, the same as cancelling the
504
+ * deploy confirmation.
505
+ */
506
+ const askHint = Effect.fn("Drizzle.askHint")(function* (props, hint, prev, cur, hints) {
507
+ const interaction = yield* Effect.serviceOption(Interaction);
508
+ if (Option.isNone(interaction))
509
+ return yield* new UndecidedSchemaChange({ props, hint });
510
+ const name = formatEntity(hint.entity);
511
+ return yield* interaction.value.prompt.select({
512
+ message: `Is ${hint.kind} ${name} created or renamed from another ${hint.kind}?`,
513
+ options: [
514
+ {
515
+ value: { type: "create", kind: hint.kind, entity: hint.entity },
516
+ label: `+ ${name}`,
517
+ description: `create ${hint.kind}`,
518
+ },
519
+ ...renameCandidates(hint, prev, cur, hints).map((from) => ({
520
+ value: { type: "rename", kind: hint.kind, from, to: hint.entity },
521
+ label: `~ ${formatEntity(from)} › ${name}`,
522
+ description: `rename ${hint.kind}`,
523
+ })),
524
+ ],
525
+ descriptionPlacement: "inline",
526
+ });
527
+ }, (effect, props, hint) => effect.pipe(Effect.catchTag("NonInteractiveTerminal", () => Effect.fail(new UndecidedSchemaChange({ props, hint })))));
372
528
  //# sourceMappingURL=Schema.js.map