| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as Option from "effect/Option"; |
| 3 | import type { RuntimeContext } from "../../RuntimeContext.ts"; |
| 4 | import { ALCHEMY_DEFAULT_TABLE } from "../../SQL/Migrations/AlchemyFormat.ts"; |
| 5 | import { |
| 6 | MigrationError, |
| 7 | type MigrationHistoryConflictError, |
| 8 | } from "../../SQL/Migrations/Format.ts"; |
| 9 | import type { DurableObjectState } from "./DurableObjectState.ts"; |
| 10 | import { applySqlMigrations } from "./SqlMigrationsApply.ts"; |
| 11 | import { |
| 12 | SqlMigrationsRuntime, |
| 13 | type SqlMigrationSnapshot, |
| 14 | type SqlMigrationsExport, |
| 15 | } from "./SqlMigrationsRuntime.ts"; |
| 16 | import { Worker } from "./Worker.ts"; |
| 17 | |
| 18 | export type { SqlMigrationSnapshot } from "./SqlMigrationsRuntime.ts"; |
| 19 | |
| 20 | /** Captured SQL migrations with a runtime-only application method. */ |
| 21 | export interface SqlMigrations extends SqlMigrationSnapshot { |
| 22 | /** |
| 23 | * Apply pending files to the current Durable Object's SQLite database. |
| 24 | * Call in the inner instance Effect before returning public methods. |
| 25 | * Each file and its history row commit atomically; applied files are skipped. |
| 26 | * Requires runtime context, which is also available in request handlers. |
| 27 | */ |
| 28 | readonly apply: () => Effect.Effect< |
| 29 | void, |
| 30 | MigrationError | MigrationHistoryConflictError, |
| 31 | DurableObjectState | RuntimeContext |
| 32 | >; |
| 33 | } |
| 34 | |
| 35 | /** A migrations directory, optionally with a custom bookkeeping table. */ |
| 36 | export type SqlMigrationsInput = |
| 37 | | string |
| 38 | | { |
| 39 | /** Directory relative to the directory where Alchemy runs. */ |
| 40 | readonly dir: string; |
| 41 | /** Applied-migrations table. Defaults to `__alchemy_migrations`. */ |
| 42 | readonly table?: string; |
| 43 | }; |
| 44 | |
| 45 | /** |
| 46 | * Read SQL migrations during construction and carry them into a Durable |
| 47 | * Object without importing `.sql` files or generated `migrations.js`. |
| 48 | * |
| 49 | * Files are ordered and hashed using Alchemy's shared SQL migration format. |
| 50 | * Flat SQL files and modern drizzle-kit migration directories are supported. |
| 51 | * Paths are relative to the directory where Alchemy runs, not this module. |
| 52 | * The records are embedded in the Worker JavaScript bundle, so they do not |
| 53 | * consume environment-variable bindings. The runtime never reads the directory. |
| 54 | * Missing directories and unsupported layouts fail construction. |
| 55 | * |
| 56 | * ### Drizzle Durable Objects |
| 57 | * **Example:** Capture SQL in the outer Effect and migrate on activation |
| 58 | * ```typescript |
| 59 | * import * as Cloudflare from "alchemy/Cloudflare"; |
| 60 | * import * as Drizzle from "alchemy/Drizzle/Cloudflare"; |
| 61 | * import * as Effect from "effect/Effect"; |
| 62 | * import { relations, users } from "./schema.ts"; |
| 63 | * |
| 64 | * export class Users extends Cloudflare.DurableObject<Users>()( |
| 65 | * "Users", |
| 66 | * Effect.gen(function* () { |
| 67 | * const migrations = yield* Cloudflare.SqlMigrations("./drizzle"); |
| 68 | * return Effect.gen(function* () { |
| 69 | * const db = yield* Drizzle.DurableObject({ migrations, relations }); |
| 70 | * return { list: () => db.select().from(users) }; |
| 71 | * }); |
| 72 | * }), |
| 73 | * ) {} |
| 74 | * ``` |
| 75 | * |
| 76 | * ### Apply Migrations Without Drizzle |
| 77 | * **Example:** Migrate before exposing the object's methods |
| 78 | * ```typescript |
| 79 | * Effect.gen(function* () { |
| 80 | * const migrations = yield* Cloudflare.SqlMigrations("./drizzle"); |
| 81 | * return Effect.gen(function* () { |
| 82 | * yield* migrations.apply().pipe(Effect.orDie); |
| 83 | * return {}; |
| 84 | * }); |
| 85 | * }); |
| 86 | * ``` |
| 87 | * |
| 88 | * `apply()` requires `RuntimeContext` and the current Durable Object state. |
| 89 | * Each pending file and its `__alchemy_migrations` row commit atomically. |
| 90 | * A failed file rolls back; successfully applied earlier files stay committed. |
| 91 | * Existing modern Drizzle history is adopted without replaying applied SQL. |
| 92 | * |
| 93 | * ### Custom Bookkeeping Table |
| 94 | * **Example:** Use the same table on every activation |
| 95 | * ```typescript |
| 96 | * const migrations = yield* Cloudflare.SqlMigrations({ |
| 97 | * dir: "./drizzle", |
| 98 | * table: "app_migrations", |
| 99 | * }); |
| 100 | * ``` |
| 101 | * |
| 102 | * @binding |
| 103 | * @product Workers |
| 104 | * @category Workers & Compute |
| 105 | */ |
| 106 | export const SqlMigrations = Effect.fn("Cloudflare.SqlMigrations")(function* ( |
| 107 | input: SqlMigrationsInput, |
| 108 | ) { |
| 109 | const { dir, table = ALCHEMY_DEFAULT_TABLE } = |
| 110 | typeof input === "string" ? { dir: input } : input; |
| 111 | const key = `alchemy:sql-migrations:${JSON.stringify([dir, table])}`; |
| 112 | if (!globalThis.__ALCHEMY_RUNTIME__) { |
| 113 | const { readMigrationRecords } = yield* Effect.promise( |
| 114 | () => import("../../SQL/Migrations/Records.ts"), |
| 115 | ); |
| 116 | const snapshot: SqlMigrationSnapshot = { |
| 117 | _tag: "Cloudflare.SqlMigrations", |
| 118 | table, |
| 119 | records: yield* readMigrationRecords(dir).pipe(Effect.orDie), |
| 120 | }; |
| 121 | yield* (yield* Worker).export(key, { |
| 122 | kind: "sqlMigrations", |
| 123 | snapshot, |
| 124 | } satisfies SqlMigrationsExport); |
| 125 | return makeSqlMigrations(snapshot); |
| 126 | } |
| 127 | const bundles = yield* Effect.serviceOption(SqlMigrationsRuntime); |
| 128 | const snapshot = Option.isSome(bundles) ? bundles.value[key] : undefined; |
| 129 | if (snapshot === undefined) { |
| 130 | return yield* Effect.die( |
| 131 | new MigrationError({ |
| 132 | message: `SQL migrations for ${dir} were not captured during construction. Call SqlMigrations in the outer Durable Object Effect.`, |
| 133 | }), |
| 134 | ); |
| 135 | } |
| 136 | return makeSqlMigrations(snapshot); |
| 137 | }); |
| 138 | |
| 139 | const makeSqlMigrations = (snapshot: SqlMigrationSnapshot): SqlMigrations => ({ |
| 140 | ...snapshot, |
| 141 | apply: () => applySqlMigrations(snapshot), |
| 142 | }); |