1import * as Effect from "effect/Effect";
2import * as Option from "effect/Option";
3import type { RuntimeContext } from "../../RuntimeContext.ts";
4import { ALCHEMY_DEFAULT_TABLE } from "../../SQL/Migrations/AlchemyFormat.ts";
5import {
6 MigrationError,
7 type MigrationHistoryConflictError,
8} from "../../SQL/Migrations/Format.ts";
9import type { DurableObjectState } from "./DurableObjectState.ts";
10import { applySqlMigrations } from "./SqlMigrationsApply.ts";
11import {
12 SqlMigrationsRuntime,
13 type SqlMigrationSnapshot,
14 type SqlMigrationsExport,
15} from "./SqlMigrationsRuntime.ts";
16import { Worker } from "./Worker.ts";
17
18export type { SqlMigrationSnapshot } from "./SqlMigrationsRuntime.ts";
19
20/** Captured SQL migrations with a runtime-only application method. */
21export 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. */
36export 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 */
106export 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
139const makeSqlMigrations = (snapshot: SqlMigrationSnapshot): SqlMigrations => ({
140 ...snapshot,
141 apply: () => applySqlMigrations(snapshot),
142});