| 1 | import { Action } from "@/Action"; |
| 2 | import * as Cloudflare from "@/Cloudflare/index.ts"; |
| 3 | import * as Alchemy from "@/index.ts"; |
| 4 | import * as Test from "@/Test/Alchemy"; |
| 5 | import * as d1 from "@distilled.cloud/cloudflare/d1"; |
| 6 | import { expect } from "alchemy-test"; |
| 7 | import * as Data from "effect/Data"; |
| 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as FileSystem from "effect/FileSystem"; |
| 10 | import * as Path from "effect/Path"; |
| 11 | import { MinimumLogLevel } from "effect/References"; |
| 12 | import * as Schedule from "effect/Schedule"; |
| 13 | import * as HttpClient from "effect/http/HttpClient"; |
| 14 | import * as pathe from "pathe"; |
| 15 | import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment.ts"; |
| 16 | |
| 17 | // `dev: true` runs local providers behind the RPC sidecar proxy by default, |
| 18 | // matching the process topology of the real `alchemy dev` command (see |
| 19 | // MakeOptions.sidecar in Test/Core.ts). |
| 20 | const { test, deploy, destroy } = Test.make({ |
| 21 | providers: Cloudflare.providers(), |
| 22 | dev: true, |
| 23 | }); |
| 24 | |
| 25 | // The in-process topology: no RpcProviderProxy, so the RPC-backed local |
| 26 | // provider builds directly in this process with the un-gated |
| 27 | // `localRuntimeServices()` from its dual registration. Still a real |
| 28 | // production path — a programmatic `deploy({ dev: true })` without the |
| 29 | // `alchemy dev` CLI has no sidecar. |
| 30 | const { test: inProcessTest } = Test.make({ |
| 31 | providers: Cloudflare.providers(), |
| 32 | dev: true, |
| 33 | sidecar: false, |
| 34 | }); |
| 35 | |
| 36 | const logLevel = Effect.provideService( |
| 37 | MinimumLogLevel, |
| 38 | process.env.DEBUG ? "Debug" : "Info", |
| 39 | ); |
| 40 | |
| 41 | const RpcMigrationStack = Alchemy.Stack( |
| 42 | "D1RpcMigrationStack", |
| 43 | { |
| 44 | providers: Cloudflare.providers(), |
| 45 | state: Alchemy.localState(), |
| 46 | }, |
| 47 | Cloudflare.D1.Database("RpcMigratedDB", { |
| 48 | migrations: pathe.resolve(import.meta.dirname, "fixtures/rpc-migrations"), |
| 49 | }), |
| 50 | ); |
| 51 | |
| 52 | class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{ |
| 53 | status: number; |
| 54 | }> {} |
| 55 | |
| 56 | const getJsonReady = (url: string) => |
| 57 | Effect.gen(function* () { |
| 58 | const client = yield* HttpClient.HttpClient; |
| 59 | const res = yield* client.get(url).pipe( |
| 60 | Effect.flatMap((res) => |
| 61 | res.status === 200 |
| 62 | ? Effect.succeed(res) |
| 63 | : Effect.fail(new WorkerNotReady({ status: res.status })), |
| 64 | ), |
| 65 | Effect.retry({ |
| 66 | while: (e): e is WorkerNotReady => e instanceof WorkerNotReady, |
| 67 | // Cap the backoff: an uncapped exponential over 10 recurs sums to |
| 68 | // ~8.5 minutes and turns a persistent non-200 into an apparent hang. |
| 69 | schedule: Schedule.max([ |
| 70 | Schedule.min([ |
| 71 | Schedule.exponential("500 millis"), |
| 72 | Schedule.spaced("2 seconds"), |
| 73 | ]), |
| 74 | Schedule.recurs(10), |
| 75 | ]), |
| 76 | }), |
| 77 | ); |
| 78 | return yield* res.json; |
| 79 | }).pipe(Effect.orDie); |
| 80 | |
| 81 | /** |
| 82 | * Regression test for #1007. Under the RPC proxy used by `alchemy dev` (the |
| 83 | * default topology for `dev: true` tests), `localRuntimeServices()` is |
| 84 | * intentionally omitted from the caller because RPC providers consume it in |
| 85 | * the sidecar. D1's local provider is not an RPC provider, so its migration |
| 86 | * lifecycle must provide the standalone gateway runtime explicitly. |
| 87 | */ |
| 88 | test( |
| 89 | "D1 migrations apply with the alchemy dev RPC proxy", |
| 90 | Effect.gen(function* () { |
| 91 | yield* destroy(RpcMigrationStack); |
| 92 | |
| 93 | const database = yield* deploy(RpcMigrationStack); |
| 94 | |
| 95 | expect(database.databaseId).toMatch(/^dev:/); |
| 96 | expect(Object.keys(database.migrationsHashes)).toEqual(["0001_notes.sql"]); |
| 97 | |
| 98 | yield* destroy(RpcMigrationStack); |
| 99 | }).pipe(logLevel), |
| 100 | { |
| 101 | tags: ["provider:cloudflare", "provider:cloudflare:d1", "local"], |
| 102 | timeout: 120_000, |
| 103 | }, |
| 104 | ); |
| 105 | |
| 106 | /** |
| 107 | * Counterpart of the #1007 regression above for the in-process topology: |
| 108 | * without the proxy, the provider's migration lifecycle runs in this |
| 109 | * process and must find the workerd runtime in its own layer (the dual |
| 110 | * registration's `localRuntimeServices()`, real when un-gated). |
| 111 | */ |
| 112 | inProcessTest.provider( |
| 113 | "D1 migrations apply in-process without the RPC proxy", |
| 114 | (stack) => |
| 115 | Effect.gen(function* () { |
| 116 | yield* stack.destroy(); |
| 117 | |
| 118 | const db = yield* stack.deploy( |
| 119 | Cloudflare.D1.Database("InProcessMigratedDB", { |
| 120 | migrations: pathe.resolve( |
| 121 | import.meta.dirname, |
| 122 | "fixtures/rpc-migrations", |
| 123 | ), |
| 124 | }), |
| 125 | ); |
| 126 | |
| 127 | expect(db.databaseId).toMatch(/^dev:/); |
| 128 | expect(Object.keys(db.migrationsHashes)).toEqual(["0001_notes.sql"]); |
| 129 | |
| 130 | yield* stack.destroy(); |
| 131 | }).pipe(logLevel), |
| 132 | { |
| 133 | tags: ["provider:cloudflare", "provider:cloudflare:d1", "local"], |
| 134 | timeout: 120_000, |
| 135 | }, |
| 136 | ); |
| 137 | |
| 138 | /** |
| 139 | * Under `alchemy dev` the D1 Database resource is emulated by the local |
| 140 | * provider (a `dev:` id, no cloud API calls) and the worker's `d1` binding |
| 141 | * is lowered onto the local workerd D1 simulator (workerd's real |
| 142 | * `cloudflare-internal:d1-api` over DO SQLite). This exercises DDL, |
| 143 | * prepared statements, and reads through the native `env` binding. |
| 144 | */ |
| 145 | test.provider( |
| 146 | "D1 database binding round-trips against the local simulator", |
| 147 | (stack) => |
| 148 | Effect.gen(function* () { |
| 149 | yield* stack.destroy(); |
| 150 | |
| 151 | const deployed = yield* stack.deploy( |
| 152 | Effect.gen(function* () { |
| 153 | const db = yield* Cloudflare.D1.Database("LocalDB"); |
| 154 | const worker = yield* Cloudflare.Worker("d1-local-worker", { |
| 155 | main: pathe.resolve( |
| 156 | import.meta.dirname, |
| 157 | "fixtures/d1-local-worker.ts", |
| 158 | ), |
| 159 | env: { DB: db }, |
| 160 | }); |
| 161 | return { db, worker }; |
| 162 | }), |
| 163 | ); |
| 164 | |
| 165 | // The local provider fabricates a `dev:` id — proof no cloud call ran. |
| 166 | expect(deployed.db.databaseId).toMatch(/^dev:/); |
| 167 | |
| 168 | const body = (yield* getJsonReady( |
| 169 | `${deployed.worker.url}/roundtrip`, |
| 170 | )) as { |
| 171 | names: string[]; |
| 172 | count: number | null; |
| 173 | }; |
| 174 | expect(body.names).toEqual(["alice", "bob"]); |
| 175 | expect(body.count).toBe(2); |
| 176 | |
| 177 | yield* stack.destroy(); |
| 178 | }).pipe(logLevel), |
| 179 | { |
| 180 | tags: [ |
| 181 | "provider:cloudflare", |
| 182 | "provider:cloudflare:d1", |
| 183 | "provider:cloudflare:worker", |
| 184 | "local", |
| 185 | ], |
| 186 | timeout: 120_000, |
| 187 | }, |
| 188 | ); |
| 189 | |
| 190 | /** |
| 191 | * Migrations apply against the local simulator: reconcile boots an |
| 192 | * ephemeral gateway workerd and drives the same migration flow the live |
| 193 | * provider uses (Alchemy's `__alchemy_migrations` table, idempotent |
| 194 | * re-application). Verified through the deployed worker's binding — the |
| 195 | * same DO SQLite storage the gateway wrote to. |
| 196 | */ |
| 197 | test.provider( |
| 198 | "D1 migrations apply against the local simulator and re-apply incrementally", |
| 199 | (stack) => |
| 200 | Effect.gen(function* () { |
| 201 | const fs = yield* FileSystem.FileSystem; |
| 202 | const path = yield* Path.Path; |
| 203 | const migrationsDir = yield* fs.makeTempDirectory({ |
| 204 | prefix: "alchemy-d1-local-migrations-", |
| 205 | }); |
| 206 | yield* fs.writeFileString( |
| 207 | path.join(migrationsDir, "0001_users.sql"), |
| 208 | [ |
| 209 | "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);", |
| 210 | "INSERT INTO users (name) VALUES ('seed');", |
| 211 | ].join("\n"), |
| 212 | ); |
| 213 | |
| 214 | yield* stack.destroy(); |
| 215 | |
| 216 | const deploy = Effect.gen(function* () { |
| 217 | const db = yield* Cloudflare.D1.Database("LocalMigratedDB", { |
| 218 | migrations: migrationsDir, |
| 219 | }); |
| 220 | const worker = yield* Cloudflare.Worker("d1-migrations-worker", { |
| 221 | main: pathe.resolve( |
| 222 | import.meta.dirname, |
| 223 | "fixtures/d1-local-worker.ts", |
| 224 | ), |
| 225 | env: { DB: db }, |
| 226 | }); |
| 227 | return { db, worker }; |
| 228 | }); |
| 229 | |
| 230 | const v1 = yield* stack.deploy(deploy); |
| 231 | expect(v1.db.databaseId).toMatch(/^dev:/); |
| 232 | expect(v1.db.migrationsTable).toBe("__alchemy_migrations"); |
| 233 | expect(Object.keys(v1.db.migrationsHashes)).toEqual(["0001_users.sql"]); |
| 234 | |
| 235 | const url = v1.worker.url!; |
| 236 | const schema = (yield* getJsonReady(`${url}/tables`)) as { |
| 237 | tables: string[]; |
| 238 | }; |
| 239 | expect(schema.tables).toContain("users"); |
| 240 | expect(schema.tables).toContain("__alchemy_migrations"); |
| 241 | |
| 242 | const users = (yield* getJsonReady(`${url}/users`)) as { |
| 243 | users: string[]; |
| 244 | }; |
| 245 | expect(users.users).toEqual(["seed"]); |
| 246 | |
| 247 | // Add a second migration — the redeploy applies ONLY the new file |
| 248 | // (0001's seed row would fail a re-run with a duplicate table error, |
| 249 | // so a passing redeploy also proves idempotency). |
| 250 | yield* fs.writeFileString( |
| 251 | path.join(migrationsDir, "0002_posts.sql"), |
| 252 | "CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT NOT NULL);", |
| 253 | ); |
| 254 | |
| 255 | const v2 = yield* stack.deploy(deploy); |
| 256 | expect(v2.db.databaseId).toBe(v1.db.databaseId); |
| 257 | expect(Object.keys(v2.db.migrationsHashes).sort()).toEqual([ |
| 258 | "0001_users.sql", |
| 259 | "0002_posts.sql", |
| 260 | ]); |
| 261 | |
| 262 | // Alchemy's shape: INTEGER ids, name-keyed. |
| 263 | const migrations = (yield* getJsonReady(`${url}/migrations`)) as { |
| 264 | migrations: Array<{ id: number; name: string }>; |
| 265 | }; |
| 266 | expect(migrations.migrations).toEqual([ |
| 267 | { id: 1, name: "0001_users.sql" }, |
| 268 | { id: 2, name: "0002_posts.sql" }, |
| 269 | ]); |
| 270 | |
| 271 | // Data written by 0001 survived the second deploy (not re-applied). |
| 272 | const usersAfter = (yield* getJsonReady(`${url}/users`)) as { |
| 273 | users: string[]; |
| 274 | }; |
| 275 | expect(usersAfter.users).toEqual(["seed"]); |
| 276 | |
| 277 | yield* stack.destroy(); |
| 278 | }).pipe(logLevel), |
| 279 | { |
| 280 | tags: [ |
| 281 | "provider:cloudflare", |
| 282 | "provider:cloudflare:d1", |
| 283 | "provider:cloudflare:worker", |
| 284 | "local", |
| 285 | ], |
| 286 | timeout: 120_000, |
| 287 | }, |
| 288 | ); |
| 289 | |
| 290 | /** |
| 291 | * `importFiles` apply locally too: an import file is multi-statement SQL, |
| 292 | * executed through the gateway with the same hash-skip semantics as the |
| 293 | * cloud import flow. Verified through the worker's binding. |
| 294 | */ |
| 295 | test.provider( |
| 296 | "D1 importFiles apply against the local simulator", |
| 297 | (stack) => |
| 298 | Effect.gen(function* () { |
| 299 | const fs = yield* FileSystem.FileSystem; |
| 300 | const path = yield* Path.Path; |
| 301 | const dir = yield* fs.makeTempDirectory({ |
| 302 | prefix: "alchemy-d1-local-import-", |
| 303 | }); |
| 304 | const importFile = path.join(dir, "seed.sql"); |
| 305 | yield* fs.writeFileString( |
| 306 | importFile, |
| 307 | [ |
| 308 | "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);", |
| 309 | "INSERT INTO users (name) VALUES ('imported');", |
| 310 | ].join("\n"), |
| 311 | ); |
| 312 | |
| 313 | yield* stack.destroy(); |
| 314 | |
| 315 | const deployed = yield* stack.deploy( |
| 316 | Effect.gen(function* () { |
| 317 | const db = yield* Cloudflare.D1.Database("LocalImportedDB", { |
| 318 | importFiles: [importFile], |
| 319 | }); |
| 320 | const worker = yield* Cloudflare.Worker("d1-import-worker", { |
| 321 | main: pathe.resolve( |
| 322 | import.meta.dirname, |
| 323 | "fixtures/d1-local-worker.ts", |
| 324 | ), |
| 325 | env: { DB: db }, |
| 326 | }); |
| 327 | return { db, worker }; |
| 328 | }), |
| 329 | ); |
| 330 | |
| 331 | expect(deployed.db.databaseId).toMatch(/^dev:/); |
| 332 | expect(Object.keys(deployed.db.importHashes)).toEqual([importFile]); |
| 333 | |
| 334 | const users = (yield* getJsonReady(`${deployed.worker.url}/users`)) as { |
| 335 | users: string[]; |
| 336 | }; |
| 337 | expect(users.users).toEqual(["imported"]); |
| 338 | |
| 339 | yield* stack.destroy(); |
| 340 | }).pipe(logLevel), |
| 341 | { |
| 342 | tags: [ |
| 343 | "provider:cloudflare", |
| 344 | "provider:cloudflare:d1", |
| 345 | "provider:cloudflare:worker", |
| 346 | "local", |
| 347 | ], |
| 348 | timeout: 120_000, |
| 349 | }, |
| 350 | ); |
| 351 | |
| 352 | /** |
| 353 | * `QueryDatabaseLocal` (the stack-eval capability used in Actions) reaches |
| 354 | * the LOCAL simulator when the database is a `dev:` row: queries tunnel |
| 355 | * through an ephemeral workerd gateway into the same DO SQLite the worker |
| 356 | * binding reads. The Action seeds; the deployed worker's native binding |
| 357 | * observes the seeded rows — proving both transports share one database. |
| 358 | */ |
| 359 | test.provider( |
| 360 | "QueryDatabaseLocal Action seeds the local simulator in dev", |
| 361 | (stack) => |
| 362 | Effect.gen(function* () { |
| 363 | yield* stack.destroy(); |
| 364 | |
| 365 | const deployed = yield* stack.deploy( |
| 366 | Effect.gen(function* () { |
| 367 | const db = yield* Cloudflare.D1.Database("ActionSeededDB"); |
| 368 | |
| 369 | const Seed = Action( |
| 370 | "Seed", |
| 371 | Effect.gen(function* () { |
| 372 | const client = yield* Cloudflare.D1.QueryDatabase(db); |
| 373 | return Effect.fn(function* () { |
| 374 | yield* client.exec( |
| 375 | "CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)", |
| 376 | ); |
| 377 | yield* client.prepare("DELETE FROM users").run(); |
| 378 | yield* client |
| 379 | .prepare("INSERT INTO users (name) VALUES (?)") |
| 380 | .bind("ada") |
| 381 | .run(); |
| 382 | const rows = yield* client |
| 383 | .prepare("SELECT name FROM users ORDER BY name") |
| 384 | .all<{ name: string }>(); |
| 385 | return { names: rows.results.map((r) => r.name) }; |
| 386 | }); |
| 387 | }).pipe(Effect.provide(Cloudflare.D1.QueryDatabaseLocal)), |
| 388 | ); |
| 389 | const seeded = yield* Seed({}); |
| 390 | |
| 391 | const worker = yield* Cloudflare.Worker("d1-action-worker", { |
| 392 | main: pathe.resolve( |
| 393 | import.meta.dirname, |
| 394 | "fixtures/d1-local-worker.ts", |
| 395 | ), |
| 396 | env: { DB: db }, |
| 397 | }); |
| 398 | return { db, worker, seeded }; |
| 399 | }), |
| 400 | ); |
| 401 | |
| 402 | expect(deployed.db.databaseId).toMatch(/^dev:/); |
| 403 | expect(deployed.seeded.names).toEqual(["ada"]); |
| 404 | |
| 405 | // The worker's native binding reads the same simulator storage the |
| 406 | // Action's gateway wrote to. |
| 407 | const users = (yield* getJsonReady(`${deployed.worker.url}/users`)) as { |
| 408 | users: string[]; |
| 409 | }; |
| 410 | expect(users.users).toEqual(["ada"]); |
| 411 | |
| 412 | yield* stack.destroy(); |
| 413 | }).pipe(logLevel), |
| 414 | { |
| 415 | tags: [ |
| 416 | "provider:cloudflare", |
| 417 | "provider:cloudflare:d1", |
| 418 | "provider:cloudflare:worker", |
| 419 | "local", |
| 420 | ], |
| 421 | timeout: 120_000, |
| 422 | }, |
| 423 | ); |
| 424 | |
| 425 | /** |
| 426 | * `Alchemy.remote()` opts the database OUT of local emulation: even under |
| 427 | * `alchemy dev` it is created on real Cloudflare (a real UUID, not a `dev:` |
| 428 | * id) and the worker's `d1` binding proxies to it remotely. An out-of-band |
| 429 | * query through the cloud API proves the worker's writes landed in the real |
| 430 | * database, and destroy removes it. |
| 431 | */ |
| 432 | test.provider( |
| 433 | "Alchemy.remote() database runs live in dev", |
| 434 | (stack) => |
| 435 | Effect.gen(function* () { |
| 436 | yield* stack.destroy(); |
| 437 | |
| 438 | const deployed = yield* stack.deploy( |
| 439 | Effect.gen(function* () { |
| 440 | const db = yield* Cloudflare.D1.Database("LiveDevDB").pipe( |
| 441 | Alchemy.remote(), |
| 442 | ); |
| 443 | const worker = yield* Cloudflare.Worker("d1-live-worker", { |
| 444 | main: pathe.resolve( |
| 445 | import.meta.dirname, |
| 446 | "fixtures/d1-local-worker.ts", |
| 447 | ), |
| 448 | env: { DB: db }, |
| 449 | }); |
| 450 | return { db, worker }; |
| 451 | }), |
| 452 | ); |
| 453 | |
| 454 | // A real UUID — the live provider created it on Cloudflare. |
| 455 | expect(deployed.db.databaseId).not.toMatch(/^dev:/); |
| 456 | |
| 457 | const body = (yield* getJsonReady( |
| 458 | `${deployed.worker.url}/roundtrip`, |
| 459 | )) as { |
| 460 | names: string[]; |
| 461 | }; |
| 462 | expect(body.names).toEqual(["alice", "bob"]); |
| 463 | |
| 464 | // Out-of-band: the rows are visible through the cloud API — the |
| 465 | // remote-proxied binding really hit the live database. |
| 466 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 467 | const queryDb = yield* d1.queryDatabase; |
| 468 | const result = yield* queryDb({ |
| 469 | accountId, |
| 470 | databaseId: deployed.db.databaseId, |
| 471 | sql: "SELECT name FROM users ORDER BY name;", |
| 472 | }); |
| 473 | const names = (result.result[0]?.results ?? []) as Array<{ |
| 474 | name: string; |
| 475 | }>; |
| 476 | expect(names.map((r) => r.name)).toEqual(["alice", "bob"]); |
| 477 | |
| 478 | yield* stack.destroy(); |
| 479 | |
| 480 | // Destroy deleted the real database (stamped live mode). |
| 481 | const gone = yield* d1 |
| 482 | .getDatabase({ accountId, databaseId: deployed.db.databaseId }) |
| 483 | .pipe( |
| 484 | Effect.as(false), |
| 485 | Effect.catchTag("DatabaseNotFound", () => Effect.succeed(true)), |
| 486 | ); |
| 487 | expect(gone).toBe(true); |
| 488 | }).pipe(logLevel), |
| 489 | { |
| 490 | tags: [ |
| 491 | "provider:cloudflare", |
| 492 | "provider:cloudflare:d1", |
| 493 | "provider:cloudflare:worker", |
| 494 | "live", |
| 495 | ], |
| 496 | timeout: 120_000, |
| 497 | }, |
| 498 | ); |