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

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 ADDED
@@ -0,0 +1,504 @@
1
+ import * as crypto from "node:crypto";
2
+ import * as Artifacts from "alchemy/Artifacts";
3
+ import { isResolved } from "alchemy/Diff";
4
+ import * as Provider from "alchemy/Provider";
5
+ import { Resource } from "alchemy/Resource";
6
+ import { exec } from "alchemy/Util/exec";
7
+ import { isNonInteractive } from "alchemy/Util/interactive";
8
+ import * as Effect from "effect/Effect";
9
+ import * as FileSystem from "effect/FileSystem";
10
+ import * as Path from "effect/Path";
11
+ import type { PlatformError } from "effect/PlatformError";
12
+ import { ChildProcess } from "effect/process";
13
+ import type { Providers } from "./Providers.ts";
14
+
15
+ export type Dialect = "postgres" | "mysql" | "sqlite";
16
+
17
+ type DrizzleSnapshot = {
18
+ id?: string;
19
+ prevIds?: string[];
20
+ };
21
+
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[]>;
29
+ };
30
+
31
+ export type SchemaProps = {
32
+ /**
33
+ * Path to the schema module, relative to the current working directory.
34
+ * The module is loaded via dynamic `import()` so drizzle-kit can introspect
35
+ * the table definitions, then diffed against the latest snapshot under
36
+ * `out` to detect changes.
37
+ *
38
+ * @example "./src/schema.ts"
39
+ */
40
+ schema: string;
41
+ /**
42
+ * Output directory for generated migrations. Each migration is written as
43
+ * `{out}/{timestamp}_migration/{migration.sql, snapshot.json}`. Pass this
44
+ * through as a database resource's `migrations` prop (`Neon.Branch`,
45
+ * `Fly.Postgres`, `Cloudflare.D1.Database`, …) to apply pending
46
+ * migrations on deploy.
47
+ *
48
+ * @default "./migrations"
49
+ */
50
+ out?: string;
51
+ /**
52
+ * SQL dialect to generate migrations for. Selects which `drizzle-kit/api-*`
53
+ * module is loaded.
54
+ *
55
+ * @default "postgres"
56
+ */
57
+ dialect?: Dialect;
58
+ };
59
+
60
+ export type Schema = Resource<
61
+ "Drizzle.Schema",
62
+ SchemaProps,
63
+ {
64
+ /** Path to the migrations directory, relative to the current working directory. */
65
+ out: string;
66
+ /**
67
+ * sha256 of the latest snapshot.json. Stable across deploys when the
68
+ * schema is unchanged; bumps trigger an update which regenerates pending
69
+ * migration SQL. Downstream `migrationsDir` consumers read this to
70
+ * detect drift and reapply.
71
+ */
72
+ snapshotHash: string;
73
+ /** Names of all migration directories under `out`, in order. */
74
+ migrations: string[];
75
+ },
76
+ never,
77
+ Providers
78
+ >;
79
+
80
+ /**
81
+ * A Drizzle schema managed as an Alchemy resource.
82
+ *
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
86
+ * intended to be passed straight to a database resource's `migrations`
87
+ * prop, giving you a single deploy-driven flow:
88
+ *
89
+ * ```typescript
90
+ * const schema = yield* Drizzle.Schema("app-schema", {
91
+ * schema: "./src/schema.ts",
92
+ * });
93
+ *
94
+ * const branch = yield* Neon.Branch("app-branch", {
95
+ * project,
96
+ * migrations: schema,
97
+ * });
98
+ *
99
+ * const db = yield* Fly.Postgres("Db", {
100
+ * region: "iad",
101
+ * migrations: schema,
102
+ * });
103
+ * ```
104
+ *
105
+ * `Drizzle.Schema` runs first (because the database resource depends
106
+ * on its `out` output), regenerates pending migration files, and the
107
+ * database resource then applies them.
108
+ *
109
+ * The resource is delete-safe: removing it from the stack does **not** wipe
110
+ * the migrations directory, since migration files are typically checked in
111
+ * and shared with other environments.
112
+ *
113
+ * @resource
114
+ */
115
+ export const Schema = Resource<Schema>("Drizzle.Schema");
116
+
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
+ };
127
+
128
+ const sha = (s: string) => crypto.createHash("sha256").update(s).digest("hex");
129
+
130
+ const tsStamp = () => new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14);
131
+
132
+ export const SchemaProvider = () =>
133
+ Provider.effect(
134
+ Schema,
135
+ Effect.gen(function* () {
136
+ const fs = yield* FileSystem.FileSystem;
137
+ const path = yield* Path.Path;
138
+
139
+ const resolveOut = (p: SchemaProps) => path.resolve(process.cwd(), p.out ?? "./migrations");
140
+
141
+ // The `out` attribute is exposed as a path relative to the current
142
+ // working directory so that persisted state stays portable across
143
+ // machines/checkouts. Internal filesystem ops always use the absolute
144
+ // `resolveOut` form.
145
+ const relativeOut = (abs: string) => path.relative(process.cwd(), abs);
146
+
147
+ 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
+ });
264
+
265
+ // List `<ts>_*` migration directories under `out`, sorted by numeric
266
+ // 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
+ });
274
+
275
+ // The latest snapshot is the head of the `prevIds` chain — the one no
276
+ // other snapshot points back to. Directory-name order alone is not
277
+ // enough: two migrations generated within the same second share the
278
+ // 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
+ });
331
+
332
+ /**
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).
337
+ */
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);
346
+ }
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"));
380
+
381
+ /**
382
+ * Generate a new migration directory if the schema has drifted from
383
+ * the latest snapshot. Returns the new state regardless.
384
+ */
385
+ const regenerate = (props: SchemaProps) =>
386
+ Effect.gen(function* () {
387
+ const drift = yield* detectDrift(props);
388
+
389
+ if (drift.mode === "cli") {
390
+ if (drift.changed) {
391
+ yield* runDrizzleGenerate(props, drift.out);
392
+ }
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
+ }
403
+
404
+ const { out, cur, sqlStatements } = drift;
405
+
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
+ );
417
+ }
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
+ });
428
+
429
+ return {
430
+ // Non-listable: a Drizzle.Schema is a local build artifact (generated
431
+ // migration SQL under `out`, a path supplied entirely by props with no
432
+ // account/store to enumerate). There is no remote enumeration API, so
433
+ // there is nothing to discover out-of-band.
434
+ list: () => Effect.succeed([]),
435
+ diff: Effect.fn(function* ({ news, output }) {
436
+ 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;
444
+ // Originally `output.out` was an absolute path, which is not portable.
445
+ // So, we trigger an update to migrate existing resources to the
446
+ // canonical (cwd-relative) form. This is safe because `regenerate`
447
+ // is idempotent. Compare against the canonical form rather than
448
+ // testing `isAbsolute`: on Windows a cross-drive `out` can never be
449
+ // relativized, so its canonical form IS absolute and an isAbsolute
450
+ // check would flag an update on every deploy.
451
+ return changed || output.out !== relativeOut(resolveOut(news))
452
+ ? { action: "update" }
453
+ : undefined;
454
+ }),
455
+ read: Effect.fn(function* ({ olds, output }) {
456
+ if (!output) return undefined;
457
+ const out = resolveOut(olds ?? ({} as SchemaProps));
458
+ const exists = yield* fs.exists(out);
459
+ if (!exists) return undefined;
460
+ const latest = yield* readLatestSnapshot(out);
461
+ const migrations = yield* listMigrationDirs(out);
462
+ return {
463
+ out: relativeOut(out),
464
+ snapshotHash: latest?.hash ?? output.snapshotHash,
465
+ migrations,
466
+ };
467
+ }),
468
+ reconcile: Effect.fn(function* ({ news, output, session }) {
469
+ yield* session.note(
470
+ `${output ? "Regenerating" : "Generating"} drizzle migrations for ${news.schema}`,
471
+ );
472
+ return yield* regenerate(news);
473
+ }),
474
+ delete: Effect.fn(function* () {
475
+ // Migrations are typically checked in; do not delete on resource
476
+ // teardown.
477
+ }),
478
+ };
479
+ }),
480
+ );
481
+
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
+ });
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ export * from "./D1.ts";
2
+
3
+ // The tagged errors drizzle's effect drivers fail with — re-exported so
4
+ // consumers can reference them without reaching into drizzle-orm
5
+ // internals. (`Effect.catchTag` needs only the tag string; these are for
6
+ // instanceof checks, schemas, and annotations.)
7
+ export {
8
+ EffectDrizzleError,
9
+ EffectDrizzleQueryError,
10
+ EffectTransactionRollbackError,
11
+ MigratorInitError,
12
+ } from "drizzle-orm/effect-core";
13
+ export * from "./Providers.ts";
14
+ export * from "./Schema.ts";
15
+ // MySQL and Postgres are deliberately NOT re-exported here: their drivers
16
+ // (`@effect/sql-mysql2`/`mysql2`, `@effect/sql-pg`/`pg`) are optional peer
17
+ // dependencies, and keeping them out of the barrel keeps them out of every
18
+ // non-user's module graph. Import them via their subpaths instead:
19
+ // `@alchemy.run/drizzle/MySQL`, `@alchemy.run/drizzle/Postgres`.