1import * as Cloudflare from "@/Cloudflare/index.ts";
2import * as Alchemy from "@/index.ts";
3import * as Test from "@/Test/Alchemy";
4import * as d1 from "@distilled.cloud/cloudflare/d1";
5import * as kv from "@distilled.cloud/cloudflare/kv";
6import * as r2 from "@distilled.cloud/cloudflare/r2";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import { MinimumLogLevel } from "effect/References";
10import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment.ts";
11import { Stack } from "@/Stack";
12import { State, type ResourceState } from "@/State";
13import { inDev } from "../test.resources.ts";
14
15const { test } = Test.make({ providers: Cloudflare.providers() });
16
17const logLevel = Effect.provideService(
18 MinimumLogLevel,
19 process.env.DEBUG ? "Debug" : "Info",
20);
21
22/**
23 * The full provider-mode cycle against the real cloud. Mode transitions are
24 * engine-orchestrated REPLACEMENTS (the persisted `providerMode` stamp vs
25 * the resolved mode; the provider diff is not consulted) — the providers
26 * themselves carry no promotion logic:
27 *
28 * 1. dev (all local) — `dev:` rows, zero cloud calls
29 * 2. dev + remote() on KV — hybrid: the namespace switches local → live
30 * WITHIN dev (replacement, real cloud id);
31 * bucket/DB stay local
32 * 3. deploy (all live) — bucket/DB promote (replacements, new real
33 * ids); the already-live namespace keeps its
34 * identity (no churn)
35 * 4. dev, remote() removed — demotion: all three switch live → local;
36 * the real cloud resources are deleted by the
37 * live provider (the stamped mode) and fresh
38 * `dev:` rows replace them
39 * 5. destroy — local rows, nothing left upstream
40 */
41test.provider(
42 "KV/R2/D1 cycle local → hybrid → live → local with engine-orchestrated replacements",
43 (stack) =>
44 Effect.gen(function* () {
45 yield* stack.destroy();
46 const { accountId } = yield* yield* CloudflareEnvironment;
47
48 const program = (liveKV: boolean) =>
49 Effect.gen(function* () {
50 const namespace = yield* Cloudflare.KV.Namespace("CycleKV").pipe(
51 Alchemy.remote(liveKV),
52 );
53 const bucket = yield* Cloudflare.R2.Bucket("CycleBucket", {
54 forceDestroy: true,
55 });
56 const database = yield* Cloudflare.D1.Database("CycleDB");
57 return { namespace, bucket, database };
58 });
59
60 const namespaceExists = (namespaceId: string) =>
61 kv.getNamespace({ accountId, namespaceId }).pipe(
62 Effect.as(true),
63 Effect.catchTag("NamespaceNotFound", () => Effect.succeed(false)),
64 );
65 const bucketExists = (bucketName: string) =>
66 r2.getBucket({ accountId, bucketName }).pipe(
67 Effect.as(true),
68 Effect.catchTag("NoSuchBucket", () => Effect.succeed(false)),
69 );
70 const databaseExists = (databaseId: string) =>
71 d1.getDatabase({ accountId, databaseId }).pipe(
72 Effect.as(true),
73 Effect.catchTag("DatabaseNotFound", () => Effect.succeed(false)),
74 );
75
76 // 1. dev — everything is a local `dev:` row.
77 const dev1 = yield* inDev(stack.deploy(program(false)));
78 expect(dev1.namespace.namespaceId).toMatch(/^dev:/);
79 expect(dev1.bucket.bucketName).toMatch(/^dev:/);
80 expect(dev1.database.databaseId).toMatch(/^dev:/);
81
82 // 2. dev + remote() on the namespace — hybrid. The engine replaces the
83 // local KV row with a real cloud namespace; the others stay local
84 // (identities unchanged — no churn from an unrelated sibling switch).
85 const dev2 = yield* inDev(stack.deploy(program(true)));
86 expect(dev2.namespace.namespaceId).not.toMatch(/^dev:/);
87 expect(yield* namespaceExists(dev2.namespace.namespaceId)).toBe(true);
88 expect(dev2.bucket.bucketName).toBe(dev1.bucket.bucketName);
89 expect(dev2.database.databaseId).toBe(dev1.database.databaseId);
90
91 // 3. plain deploy — bucket/DB promote to real resources via
92 // replacement (new identities); the namespace was ALREADY live, so
93 // its identity is stable across the promotion (same-mode noop).
94 const live = yield* stack.deploy(program(true));
95 expect(live.namespace.namespaceId).toBe(dev2.namespace.namespaceId);
96 expect(live.bucket.bucketName).not.toMatch(/^dev:/);
97 expect(live.database.databaseId).not.toMatch(/^dev:/);
98 expect(yield* bucketExists(live.bucket.bucketName)).toBe(true);
99 expect(yield* databaseExists(live.database.databaseId)).toBe(true);
100
101 // 4. back to dev with remote() removed — demotion. Every resource
102 // switches live → local: fresh `dev:` rows, and the live provider
103 // (the stamped mode that created them) deletes the cloud resources.
104 const dev3 = yield* inDev(stack.deploy(program(false)));
105 expect(dev3.namespace.namespaceId).toMatch(/^dev:/);
106 expect(dev3.bucket.bucketName).toMatch(/^dev:/);
107 expect(dev3.database.databaseId).toMatch(/^dev:/);
108 expect(yield* namespaceExists(live.namespace.namespaceId)).toBe(false);
109 expect(yield* bucketExists(live.bucket.bucketName)).toBe(false);
110 expect(yield* databaseExists(live.database.databaseId)).toBe(false);
111
112 // 5. destroy — local rows only; nothing upstream to clean.
113 yield* stack.destroy();
114 }).pipe(logLevel),
115 {
116 tags: [
117 "provider:cloudflare",
118 "provider:cloudflare:d1",
119 "provider:cloudflare:kv",
120 "provider:cloudflare:r2",
121 "live",
122 ],
123 timeout: 180_000,
124 },
125);
126
127/**
128 * Regression: state rows written by alchemy versions before providerMode
129 * stamping (≤ 2.0.0-beta.64) in dev mode have no `providerMode` stamp but
130 * carry `dev:`-marker identities. A plain destroy on a newer version must
131 * infer "local" from the marker and route the delete to the local provider
132 * variant instead of handing the `dev:` id to the live provider — the
133 * Cloudflare API rejects it with `BadRequest: There is a malformed
134 * parameter in the request`, permanently wedging the destroy.
135 */
136test.provider(
137 "a legacy pre-stamping dev queue row (no providerMode, dev: id) destroys cleanly in live mode",
138 (stack) =>
139 Effect.gen(function* () {
140 yield* stack.destroy();
141 const state = yield* yield* State;
142 const stk = yield* Stack;
143 const { accountId } = yield* yield* CloudflareEnvironment;
144
145 // The exact shape `alchemy dev` on beta.64 persisted for a local
146 // queue: a fabricated `dev:<uuid>` id and no providerMode field.
147 const fqn = "LegacyDevQueue";
148 yield* state.set({
149 stack: stk.name,
150 stage: stk.stage,
151 fqn,
152 value: {
153 kind: "resource",
154 status: "created",
155 fqn,
156 logicalId: fqn,
157 instanceId: "beta64legacyrow0",
158 namespace: undefined,
159 resourceType: "Cloudflare.Queues.Queue",
160 providerVersion: 0,
161 props: {},
162 attr: {
163 queueId: "dev:00000000-0000-4000-8000-000000000000",
164 queueName: "legacy-dev-queue",
165 accountId,
166 },
167 bindings: [],
168 downstream: [],
169 } satisfies ResourceState,
170 });
171
172 // Live-mode destroy: the marker-inferred local variant deletes the
173 // row (a no-op against its empty in-memory registry) — no cloud call.
174 yield* stack.destroy();
175
176 expect(
177 yield* state.get({ stack: stk.name, stage: stk.stage, fqn }),
178 ).toBeUndefined();
179 }).pipe(logLevel),
180 { tags: ["provider:cloudflare", "live"], timeout: 120_000 },
181);