1import type { RuntimeServices } from "@alchemy.run/cloudflare-runtime/core";
2import * as d1 from "@distilled.cloud/cloudflare/d1";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Stream from "effect/Stream";
7import * as HttpClient from "effect/http/HttpClient";
8
9import { isResolved } from "../../Diff.ts";
10import * as ProviderLayer from "../../Local/ProviderLayer.ts";
11import * as RpcProvider from "../../Local/RpcProvider.ts";
12import { createPhysicalName } from "../../PhysicalName.ts";
13import * as Provider from "../../Provider.ts";
14import { isResourceOfType, Resource } from "../../Resource.ts";
15import {
16 diffMigrations,
17 migrationsAttrs,
18 migrationsInputOf,
19 runMigrations,
20 stampedOf,
21 type MigrationsInput,
22} from "../../SQL/Migrations/index.ts";
23import { hashImports, readSqlFile } from "../../SQL/SqlFile.ts";
24import { recordsEqual } from "../../Util/equal.ts";
25import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
26import { localAccountId } from "../LocalAccount.ts";
27import {
28 generateLocalId,
29 LOCAL_PROVIDERS_URL,
30 localRuntimeServices,
31} from "../LocalRuntime.ts";
32import type { Providers } from "../Providers.ts";
33import { makeD1MigrationExecutor } from "./ApplyMigrations.ts";
34import { cloneDatabase } from "./CloneDatabase.ts";
35import { importD1Database } from "./ImportDatabase.ts";
36import { withLocalD1Executor } from "./LocalD1Gateway.ts";
37
38export const isDatabase = (value: unknown): value is Database =>
39 isResourceOfType(value, "Cloudflare.D1Database");
40
41export type Jurisdiction = "default" | "eu" | "fedramp";
42export type PrimaryLocationHint =
43 | "wnam"
44 | "enam"
45 | "weur"
46 | "eeur"
47 | "apac"
48 | "oc";
49
50export type CloneSource = Database | { databaseId: string } | { name: string };
51
52export type DatabaseProps = {
53 /**
54 * Name of the database. If omitted, a unique name will be generated.
55 * @default ${app}-${stage}-${id}
56 */
57 name?: string;
58 /**
59 * Region in which the primary copy of the data is stored. Cannot be
60 * changed after creation — updating this property triggers a replacement.
61 *
62 * - `wnam` — Western North America
63 * - `enam` — Eastern North America
64 * - `weur` — Western Europe
65 * - `eeur` — Eastern Europe
66 * - `apac` — Asia Pacific
67 * - `oc` — Oceania
68 */
69 primaryLocationHint?: PrimaryLocationHint;
70 /**
71 * Read replication configuration. The only mutable property after
72 * creation; toggling `mode` triggers an in-place update.
73 *
74 * @default { mode: "disabled" }
75 */
76 readReplication?: {
77 mode: "auto" | "disabled";
78 };
79 /**
80 * Jurisdiction in which the database data is guaranteed to be stored.
81 * Cannot be changed after creation.
82 *
83 * @default "default"
84 */
85 jurisdiction?: Jurisdiction;
86 /**
87 * SQL migrations to apply on deploy. Accepts a directory path, a
88 * `Drizzle.Schema` resource, or a `{ dir, table? }` object.
89 *
90 * Bookkeeping always lives in Alchemy's `__alchemy_migrations` table. A
91 * database previously migrated with `drizzle-kit migrate` or
92 * `wrangler d1 migrations apply` is adopted by a ONE-WAY conversion on
93 * first deploy: the old tool's applied history is copied into Alchemy's
94 * table (validated against the local files) and the old table is left
95 * frozen — never written, never dropped. From then on Alchemy owns the
96 * migration state.
97 *
98 * Pending migrations are detected on each deploy and applied in order as
99 * part of `update`.
100 */
101 migrations?: MigrationsInput;
102 /**
103 * Paths to additional `.sql` files to import after migrations are
104 * applied. Each file is uploaded via Cloudflare's D1 import API and
105 * hashed; only files whose contents change are re-imported on subsequent
106 * deploys.
107 *
108 * @see https://developers.cloudflare.com/d1/best-practices/import-export-data/
109 */
110 importFiles?: string[];
111 /**
112 * Clone data from an existing database during creation by exporting the
113 * source and importing it into the new database. Only applied during the
114 * `create` phase.
115 *
116 * Accepts:
117 * - another `D1Database` resource (uses its `databaseId`)
118 * - `{ databaseId }` — clone by explicit UUID
119 * - `{ name }` — look up the source by name and clone it
120 */
121 clone?: CloneSource;
122};
123
124export type Database = Resource<
125 "Cloudflare.D1Database",
126 DatabaseProps,
127 {
128 databaseId: string;
129 databaseName: string;
130 jurisdiction: Jurisdiction;
131 readReplication: { mode: "auto" | "disabled" } | undefined;
132 accountId: string;
133 migrationsDir: string | undefined;
134 migrationsTable: string | undefined;
135 migrationsHashes: Record<string, string>;
136 importHashes: Record<string, string>;
137 },
138 never,
139 Providers
140>;
141
142/**
143 * A Cloudflare D1 serverless SQL database built on SQLite.
144 *
145 * D1 is a serverless relational database that runs at the edge. Create a
146 * database as a resource, then bind it to a Worker to run SQL queries.
147 * ### Creating a Database
148 * **Example:** Basic database
149 * ```typescript
150 * const db = yield* Cloudflare.D1.Database("my-db");
151 * ```
152 *
153 * **Example:** Database with location hint
154 * The primary copy of the data is stored in the chosen region; reads can be
155 * served closer to users when read replication is enabled.
156 * ```typescript
157 * const db = yield* Cloudflare.D1.Database("my-db", {
158 * primaryLocationHint: "wnam",
159 * });
160 * ```
161 *
162 * **Example:** Database with read replication
163 * Read replication is the only mutable property after creation — toggling it
164 * triggers an update rather than a replacement.
165 * ```typescript
166 * const db = yield* Cloudflare.D1.Database("my-db", {
167 * readReplication: { mode: "auto" },
168 * });
169 * ```
170 *
171 * **Example:** Database in a specific jurisdiction
172 * ```typescript
173 * const db = yield* Cloudflare.D1.Database("my-db", {
174 * jurisdiction: "eu",
175 * });
176 * ```
177 *
178 * ### Migrations
179 * Point `migrations` at a folder of migration files. Already-applied
180 * migrations are skipped on subsequent deploys; new files are detected
181 * automatically and applied as part of the next update.
182 *
183 * Bookkeeping always lives in Alchemy's `__alchemy_migrations` table —
184 * one format, owned by Alchemy. A database previously migrated with
185 * drizzle-kit, Prisma, or wrangler is adopted by a one-way conversion on
186 * first deploy: the old tool's applied history is copied into Alchemy's
187 * table and the old table is left frozen (never written, never dropped).
188 * No baselining required. Legacy Alchemy tracking tables are detected by
189 * column shape and upgraded in place.
190 *
191 * **Example:** Apply migrations from a directory
192 * ```typescript
193 * const db = yield* Cloudflare.D1.Database("my-db", {
194 * migrations: "./migrations",
195 * });
196 * ```
197 *
198 * **Example:** Drizzle migrations (adopts an existing drizzle-kit-migrated database)
199 * ```typescript
200 * const schema = yield* Drizzle.Schema("app-schema", {
201 * schema: "./src/schema.ts",
202 * dialect: "sqlite",
203 * });
204 * const db = yield* Cloudflare.D1.Database("my-db", {
205 * migrations: schema,
206 * });
207 * ```
208 *
209 * **Example:** Custom bookkeeping table name
210 * ```typescript
211 * const db = yield* Cloudflare.D1.Database("my-db", {
212 * migrations: { dir: "./migrations", table: "my_migrations" },
213 * });
214 * ```
215 *
216 * ### Importing SQL
217 * Use `importFiles` to seed the database with raw `.sql` files via Cloudflare's
218 * D1 import API. Each file is hashed; only files whose contents change are
219 * re-imported on subsequent deploys.
220 *
221 * **Example:** Seed a database with SQL files
222 * ```typescript
223 * const db = yield* Cloudflare.D1.Database("my-db", {
224 * importFiles: ["./seed/users.sql", "./seed/posts.sql"],
225 * });
226 * ```
227 *
228 * ### Cloning a Database
229 * `clone` performs a full export → import from a source database during
230 * creation. It accepts a `D1Database` resource, a `{ databaseId }`, or a
231 * `{ name }` to look up by name.
232 *
233 * **Example:** Clone by passing the source resource directly
234 * ```typescript
235 * const source = yield* Cloudflare.D1.Database("source-db");
236 * const cloned = yield* Cloudflare.D1.Database("cloned-db", {
237 * clone: source,
238 * });
239 * ```
240 *
241 * **Example:** Clone by databaseId
242 * ```typescript
243 * const cloned = yield* Cloudflare.D1.Database("cloned-db", {
244 * clone: { databaseId: "abcdef12-3456-7890-abcd-ef1234567890" },
245 * });
246 * ```
247 *
248 * **Example:** Clone by name
249 * ```typescript
250 * const cloned = yield* Cloudflare.D1.Database("cloned-db", {
251 * clone: { name: "source-db" },
252 * });
253 * ```
254 *
255 * ### Binding to a Worker
256 * **Example:** Using D1 inside a Worker
257 * ```typescript
258 * const db = yield* Cloudflare.D1.QueryDatabase(MyDatabase);
259 *
260 * // Run a query
261 * const results = yield* db.prepare("SELECT * FROM users WHERE id = ?")
262 * .bind(userId)
263 * .all();
264 *
265 * // Execute a mutation
266 * yield* db.prepare("INSERT INTO users (id, name) VALUES (?, ?)")
267 * .bind(newId, name)
268 * .run();
269 * ```
270 *
271 * @see https://developers.cloudflare.com/d1/
272 *
273 * @resource
274 * @product D1
275 * @category Storage & Databases
276 */
277export const Database = Resource<Database>("Cloudflare.D1Database");
278
279export const ProviderLive = () =>
280 Provider.succeed(Database, {
281 stables: ["databaseId", "accountId"],
282 diff: Effect.fn(function* ({ id, olds = {}, news = {}, output }) {
283 const { accountId } = yield* yield* CloudflareEnvironment;
284 if (!isResolved(news)) return undefined;
285 if ((output?.accountId ?? accountId) !== accountId) {
286 return { action: "replace" } as const;
287 }
288 const oldName =
289 output?.databaseName ?? (yield* createDatabaseName(id, olds.name));
290 // Auto-generated names are engine-owned: the deployed name stays
291 // authoritative even if the generator would name this id differently
292 // today. Only an explicit user-provided name can force a replace.
293 const name = news.name ?? oldName;
294 const oldJurisdiction =
295 output?.jurisdiction ?? olds.jurisdiction ?? "default";
296 if (
297 oldName !== name ||
298 oldJurisdiction !== (news.jurisdiction ?? "default") ||
299 (olds.primaryLocationHint !== news.primaryLocationHint &&
300 news.primaryLocationHint !== undefined)
301 ) {
302 return { action: "replace" } as const;
303 }
304 const oldReplicationMode =
305 output?.readReplication?.mode ??
306 olds.readReplication?.mode ??
307 "disabled";
308 const newReplicationMode = news.readReplication?.mode ?? "disabled";
309 if (oldReplicationMode !== newReplicationMode) {
310 return { action: "update" } as const;
311 }
312 // Detect migration/import file drift.
313 if (yield* diffMigrations({ news, output })) {
314 return { action: "update" } as const;
315 }
316 if (news.importFiles?.length) {
317 const newHashes = yield* hashImports(news.importFiles, yield* rootDir);
318 const oldHashes = output?.importHashes ?? {};
319 if (!recordsEqual(newHashes, oldHashes)) {
320 return { action: "update" } as const;
321 }
322 }
323 return undefined;
324 }),
325 // Account-scoped collection — D1 databases live under an account and the
326 // distilled `listDatabases` op paginates them. Hydrate each row into the
327 // same Attributes shape `read`'s name-lookup branch returns.
328 list: Effect.fn(function* () {
329 const { accountId } = yield* yield* CloudflareEnvironment;
330 return yield* d1.listDatabases.pages({ accountId }).pipe(
331 Stream.runCollect,
332 Effect.map((chunk) =>
333 Array.from(chunk).flatMap((page) =>
334 (page.result ?? [])
335 .filter(
336 (db): db is (typeof page.result)[number] & { uuid: string } =>
337 db.uuid != null,
338 )
339 .map((db) => ({
340 databaseId: db.uuid,
341 databaseName: db.name ?? db.uuid,
342 jurisdiction: (db.jurisdiction ?? "default") as Jurisdiction,
343 readReplication: undefined,
344 accountId,
345 migrationsDir: undefined,
346 migrationsTable: undefined,
347 migrationsHashes: {},
348 importHashes: {},
349 })),
350 ),
351 ),
352 );
353 }),
354 read: Effect.fn(function* ({ id, output, olds }) {
355 const { accountId } = yield* yield* CloudflareEnvironment;
356 if (output?.databaseId) {
357 return yield* d1
358 .getDatabase({
359 accountId: output.accountId,
360 databaseId: output.databaseId,
361 })
362 .pipe(
363 Effect.map((db) => ({
364 databaseId: db.uuid ?? output.databaseId,
365 databaseName: db.name ?? output.databaseName,
366 jurisdiction: output.jurisdiction,
367 // Distilled widened generated string enums to open unions.
368 readReplication: (db.readReplication ?? undefined) as
369 | { mode: "auto" | "disabled" }
370 | undefined,
371 accountId: output.accountId,
372 migrationsDir: output.migrationsDir,
373 migrationsTable: output.migrationsTable,
374 migrationsHashes: output.migrationsHashes,
375 importHashes: output.importHashes,
376 })),
377 Effect.catchTag("DatabaseNotFound", () =>
378 Effect.succeed(undefined),
379 ),
380 );
381 }
382 const name = yield* createDatabaseName(id, olds?.name);
383 const match = yield* d1.listDatabases.items({ accountId, name }).pipe(
384 Stream.filter((db) => db.name === name),
385 Stream.runHead,
386 Effect.map(Option.getOrUndefined),
387 );
388 if (match) {
389 return {
390 databaseId: match.uuid!,
391 databaseName: match.name ?? name,
392 jurisdiction: (olds?.jurisdiction ?? "default") as Jurisdiction,
393 readReplication: olds?.readReplication,
394 accountId,
395 migrationsDir: (olds && migrationsInputOf(olds))?.dir,
396 migrationsTable: (olds && migrationsInputOf(olds))?.table,
397 migrationsHashes: {},
398 importHashes: {},
399 };
400 }
401 return undefined;
402 }),
403 reconcile: Effect.fn(function* ({ id, news = {}, output }) {
404 const { accountId } = yield* yield* CloudflareEnvironment;
405 const name = yield* createDatabaseName(id, news.name);
406 const jurisdiction = news.jurisdiction ?? "default";
407 const acct = output?.accountId ?? accountId;
408
409 // Observe — re-fetch the cached database; fall back to a name
410 // lookup so we recover from out-of-band deletes or partial
411 // state-persistence failures (the create call may have written
412 // the database but lost the result before persist).
413 let observed:
414 | {
415 uuid?: string | null;
416 name?: string | null;
417 // Distilled widened generated string enums to open unions.
418 readReplication?: { mode: string } | null;
419 }
420 | undefined;
421 if (output?.databaseId) {
422 observed = yield* d1
423 .getDatabase({
424 accountId: acct,
425 databaseId: output.databaseId,
426 })
427 .pipe(
428 Effect.catchTag("DatabaseNotFound", () =>
429 Effect.succeed(undefined),
430 ),
431 );
432 }
433 if (!observed) {
434 observed = yield* d1.listDatabases
435 .items({ accountId: acct, name })
436 .pipe(
437 Stream.filter((db) => db.name === name),
438 Stream.runHead,
439 Effect.map(Option.getOrUndefined),
440 );
441 }
442
443 // Ensure — create if missing. Cloudflare returns
444 // `InvalidProperty` when a database with the same name already
445 // exists; we tolerate the race by re-listing to find it.
446 let databaseId: string;
447 let databaseName: string;
448 const isFirstCreation = !observed;
449 if (!observed) {
450 const db = yield* d1
451 .createDatabase({
452 accountId: acct,
453 name,
454 jurisdiction: jurisdiction !== "default" ? jurisdiction : undefined,
455 primaryLocationHint: news.primaryLocationHint,
456 })
457 .pipe(
458 Effect.catchTag("InvalidProperty", () =>
459 Effect.gen(function* () {
460 const match = yield* d1.listDatabases
461 .items({ accountId: acct, name })
462 .pipe(
463 Stream.filter((db) => db.name === name),
464 Stream.runHead,
465 Effect.map(Option.getOrUndefined),
466 );
467 if (match) {
468 return match;
469 }
470 return yield* Effect.die(
471 `Database with name "${name}" already exists but could not be found`,
472 );
473 }),
474 ),
475 );
476 databaseId = db.uuid!;
477 databaseName = db.name ?? name;
478 } else {
479 databaseId = observed.uuid ?? output!.databaseId;
480 databaseName = observed.name ?? name;
481 }
482
483 // Sync read replication — the only mutable property on the
484 // database resource itself. Always patch with the desired mode
485 // so adoption converges drifted state.
486 const desiredReplicationMode = news.readReplication?.mode ?? "disabled";
487 const observedReplicationMode =
488 observed?.readReplication?.mode ?? "disabled";
489 if (
490 isFirstCreation
491 ? desiredReplicationMode !== "disabled"
492 : observedReplicationMode !== desiredReplicationMode
493 ) {
494 const updated = yield* d1.patchDatabase({
495 accountId: acct,
496 databaseId,
497 readReplication: { mode: desiredReplicationMode },
498 });
499 databaseId = updated.uuid ?? databaseId;
500 databaseName = updated.name ?? databaseName;
501 }
502
503 // Clone is a one-shot seed performed only on first creation.
504 // Re-running it on an existing database would clobber data.
505 if (isFirstCreation && news.clone) {
506 const sourceId = yield* resolveCloneSource(news.clone, acct);
507 yield* cloneDatabase({
508 accountId: acct,
509 sourceDatabaseId: sourceId,
510 targetDatabaseId: databaseId,
511 });
512 }
513
514 // Sync migrations — the shared pipeline is idempotent
515 // (already-applied entries are skipped), so this works for both
516 // first create and ongoing updates. Foreign (drizzle/prisma/
517 // wrangler) or legacy history is converted into
518 // __alchemy_migrations on first contact.
519 const migrationsInput = migrationsInputOf(news);
520 const migrations = migrationsInput
521 ? yield* runMigrations({
522 input: migrationsInput,
523 stamped: stampedOf(output),
524 withExecutor: (apply) =>
525 Effect.gen(function* () {
526 const queryDb = yield* d1.queryDatabase;
527 yield* apply(
528 makeD1MigrationExecutor((sql) =>
529 queryDb({ accountId: acct, databaseId, sql }),
530 ),
531 );
532 }),
533 })
534 : undefined;
535
536 // Sync imports — `runImports` skips files whose hash matches
537 // previously-imported state. On first create the previous map
538 // is empty so all listed files import.
539 const importHashes = news.importFiles?.length
540 ? yield* runImports(
541 acct,
542 databaseId,
543 news.importFiles,
544 yield* rootDir,
545 output?.importHashes ?? {},
546 )
547 : {};
548
549 return {
550 databaseId,
551 databaseName,
552 jurisdiction: (output?.jurisdiction ?? jurisdiction) as Jurisdiction,
553 readReplication: news.readReplication,
554 accountId: acct,
555 // On first creation prior state is meaningless; otherwise removing
556 // `migrations` keeps the stamp and hashes for a later re-add.
557 ...migrationsAttrs({
558 input: migrationsInput,
559 run: migrations,
560 output: isFirstCreation ? undefined : output,
561 }),
562 importHashes,
563 };
564 }),
565 delete: Effect.fn(function* ({ output }) {
566 yield* d1
567 .deleteDatabase({
568 accountId: output.accountId,
569 databaseId: output.databaseId,
570 })
571 .pipe(Effect.catchTag("DatabaseNotFound", () => Effect.void));
572 }),
573 });
574
575/**
576 * Local (dev) provider — the database is purely virtual: a `dev:` id keyed
577 * into the local workerd D1 simulator (DO SQLite under `.alchemy/local/d1`).
578 * `toRuntimeBinding` lowers a `d1` binding whose id is `dev:`-prefixed onto
579 * the local D1 service.
580 *
581 * Migrations ARE applied locally: reconcile boots an ephemeral gateway
582 * workerd (see `LocalD1Gateway.ts`) and drives the same executor-agnostic
583 * migration flow the live provider uses, against the simulator's storage.
584 *
585 * RPC-backed: under `alchemy dev` (an `RpcProviderProxy` in context) the
586 * whole lifecycle runs in the Cloudflare sidecar process — where
587 * `localRuntimeServices()` is real and shared with the Worker/Queue/
588 * Container local providers — instead of in the user's process where that
589 * layer is gated empty (the root cause of #1007). In-process runs (no
590 * proxy: `sidecar: false` tests, a plain deploy deleting a local-mode row)
591 * build the provider directly with the un-gated runtime from the `dual`
592 * registration.
593 */
594export const ProviderLocal = () =>
595 RpcProvider.effect(
596 Database,
597 LOCAL_PROVIDERS_URL,
598 Effect.gen(function* () {
599 // The local runtime services (workerd `Runtime`, binding plugins) and
600 // the HTTP client are resolved once at layer build and closed over —
601 // lifecycle effects run with the engine's call-time context, which
602 // doesn't include them.
603 const runtimeContext = yield* Effect.context<
604 RuntimeServices | HttpClient.HttpClient
605 >();
606
607 return {
608 stables: ["accountId"],
609 diff: Effect.fn(function* ({ news = {}, output }) {
610 const accountId = yield* localAccountId;
611 if (!output?.databaseId) return { action: "update" } as const;
612 if (!isResolved(news)) return undefined;
613 if (output.accountId !== accountId) {
614 return { action: "replace" } as const;
615 }
616 // Detect migration/import file drift — same rules as the live
617 // provider.
618 if (yield* diffMigrations({ news, output })) {
619 return { action: "update" } as const;
620 }
621 if (news.importFiles?.length) {
622 const newHashes = yield* hashImports(
623 news.importFiles,
624 yield* rootDir,
625 );
626 if (!recordsEqual(newHashes, output.importHashes ?? {})) {
627 return { action: "update" } as const;
628 }
629 }
630 // Fall through to the engine's default prop diff.
631 }),
632 read: Effect.fn(function* ({ output }) {
633 // Purely virtual — the persisted state row is the source of truth.
634 return output ?? undefined;
635 }),
636 reconcile: Effect.fn(function* ({ id, news = {}, output }) {
637 const accountId = yield* localAccountId;
638 const databaseId = output?.databaseId ?? generateLocalId();
639
640 // Sync migrations — the shared pipeline, driven through the
641 // ephemeral local gateway so the SAME bookkeeping applies
642 // locally as in the cloud.
643 const migrationsInput = migrationsInputOf(news);
644 const migrations = migrationsInput
645 ? yield* runMigrations({
646 input: migrationsInput,
647 stamped: stampedOf(output),
648 withExecutor: (apply) =>
649 withLocalD1Executor(databaseId, (executor) =>
650 apply(makeD1MigrationExecutor(executor)),
651 ).pipe(Effect.provideContext(runtimeContext)),
652 })
653 : undefined;
654
655 // Sync imports — locally an import file is just multi-statement
656 // SQL, executed through the same gateway. Files whose hash matches
657 // previously-imported state are skipped (mirroring `runImports`).
658 const importHashes: Record<string, string> = {
659 ...(output?.importHashes ?? {}),
660 };
661 if (news.importFiles?.length) {
662 const importRootDir = yield* rootDir;
663 const pending: Array<{ path: string; sql: string; hash: string }> =
664 [];
665 for (const filePath of news.importFiles) {
666 const file = yield* readSqlFile(importRootDir, filePath);
667 if (importHashes[filePath] === file.hash) continue;
668 pending.push({ path: filePath, sql: file.sql, hash: file.hash });
669 }
670 if (pending.length > 0) {
671 yield* withLocalD1Executor(databaseId, (executor) =>
672 Effect.forEach(pending, (file) => executor(file.sql), {
673 discard: true,
674 }),
675 ).pipe(Effect.provideContext(runtimeContext));
676 for (const file of pending) {
677 importHashes[file.path] = file.hash;
678 }
679 }
680 }
681
682 return {
683 databaseId,
684 databaseName: yield* createDatabaseName(id, news.name),
685 jurisdiction: (news.jurisdiction ?? "default") as Jurisdiction,
686 readReplication: news.readReplication,
687 accountId: output?.accountId ?? accountId,
688 ...migrationsAttrs({
689 input: migrationsInput,
690 run: migrations,
691 output,
692 }),
693 importHashes,
694 };
695 }),
696 delete: Effect.fn(function* () {
697 // The simulator's on-disk data is keyed by the dev id; dropping
698 // the state row is enough — orphaned data is reclaimed with
699 // `.alchemy`.
700 }),
701 };
702 }),
703 );
704
705export const DatabaseProvider = () =>
706 ProviderLayer.dual(Database, {
707 // The local provider's reconcile boots an ephemeral workerd gateway to
708 // apply migrations, so it needs the shared local runtime layer. Under
709 // `alchemy dev` the provider is an RPC stub (this gated layer is empty
710 // and unused) and the provider group (`../Local.ts`) supplies the real
711 // runtime; without the proxy the provider builds in-process and this
712 // layer is real.
713 local: () => ProviderLocal().pipe(Layer.provide(localRuntimeServices())),
714 live: () => ProviderLive(),
715 });
716
717const createDatabaseName = (id: string, name: string | undefined) =>
718 Effect.gen(function* () {
719 return name ?? (yield* createPhysicalName({ id }));
720 });
721
722const rootDir = Effect.sync(() => process.cwd());
723
724/**
725 * Resolve a clone source spec into a concrete database UUID. Looks up by
726 * name through `listDatabases` when only a name is provided.
727 */
728const resolveCloneSource = (source: CloneSource, accountId: string) =>
729 Effect.gen(function* () {
730 if ("databaseId" in source && source.databaseId) {
731 // At lifecycle time, Output<string> attributes have resolved to strings.
732 return source.databaseId as unknown as string;
733 }
734 if ("name" in source && source.name) {
735 const name = source.name as unknown as string;
736 const match = yield* d1.listDatabases.items({ accountId, name }).pipe(
737 Stream.filter((db) => db.name === name),
738 Stream.runHead,
739 Effect.map(Option.getOrUndefined),
740 );
741 if (!match?.uuid) {
742 return yield* Effect.die(
743 `Source database "${name}" not found for cloning`,
744 );
745 }
746 return match.uuid;
747 }
748 return yield* Effect.die(
749 "Invalid clone source: must provide databaseId or name",
750 );
751 });
752
753/**
754 * Read each `importFiles` entry and run it through the D1 import flow,
755 * skipping files whose hash matches the previously-imported hash.
756 */
757const runImports = (
758 accountId: string,
759 databaseId: string,
760 importFiles: ReadonlyArray<string>,
761 rootDir: string,
762 previous: Record<string, string>,
763) =>
764 Effect.gen(function* () {
765 const hashes: Record<string, string> = { ...previous };
766 for (const filePath of importFiles) {
767 const file = yield* readSqlFile(rootDir, filePath);
768 if (previous[filePath] === file.hash) {
769 hashes[filePath] = file.hash;
770 continue;
771 }
772 yield* importD1Database({
773 accountId,
774 databaseId,
775 sqlData: file.sql,
776 filename: file.id,
777 });
778 hashes[filePath] = file.hash;
779 }
780 // Drop entries for files no longer listed.
781 const tracked = new Set(importFiles);
782 for (const key of Object.keys(hashes)) {
783 if (!tracked.has(key)) delete hashes[key];
784 }
785 return hashes;
786 });