1import type { CloudflareResolvedCredentials } from "@/Cloudflare/Auth/AuthConfig.ts";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment.ts";
3import { type Database, DatabaseProvider } from "@/Cloudflare/D1/Database.ts";
4import {
5 type Connection,
6 ConnectionProvider,
7} from "@/Cloudflare/Hyperdrive/Connection.ts";
8import {
9 type Namespace,
10 NamespaceProvider,
11} from "@/Cloudflare/KV/Namespace.ts";
12import { type Queue, QueueProvider } from "@/Cloudflare/Queues/Queue.ts";
13import { type Bucket, BucketProvider } from "@/Cloudflare/R2/Bucket.ts";
14import {
15 type Index,
16 IndexProvider,
17} from "@/Cloudflare/Vectorize/VectorizeIndex.ts";
18import { AlchemyContext } from "@/AlchemyContext.ts";
19import { ArtifactStore, createArtifactStore } from "@/Artifacts.ts";
20import { LocalRuntimeState } from "@/Cloudflare/LocalRuntime.ts";
21import { InstanceId } from "@/InstanceId.ts";
22import { Provider } from "@/Provider.ts";
23import { Stack, type StackSpec } from "@/Stack.ts";
24import { Stage } from "@/Stage.ts";
25import {
26 apiTokenCredentials,
27 Credentials,
28} from "@distilled.cloud/cloudflare/Credentials";
29import { NodeServices } from "@effect/platform-node";
30import { describe, expect, it } from "alchemy-test";
31import * as Effect from "effect/Effect";
32import * as Layer from "effect/Layer";
33import * as MutableHashMap from "effect/MutableHashMap";
34import * as Redacted from "effect/Redacted";
35import * as FetchHttpClient from "effect/http/FetchHttpClient";
36
37// Regression tests for the "engine-owned names" invariant: a provider's
38// `diff` must never order a replace (or rename) because the physical-name
39// generator's output for a logical id has drifted from the deployed name.
40// This is exactly what happens when the naming algorithm evolves (e.g. the
41// truncation fix) — deployed resources keep their old-style names, while a
42// fresh recompute yields a new-style name. Before the guard, that mismatch
43// deleted the deployed resource (databases included) via replace.
44//
45// Live suites can never catch this class of bug: a fresh deploy generates
46// names with the current algorithm, so `output` always matches the
47// recompute. These tests simulate drift directly by handing the real
48// provider `diff` a persisted output whose name no generator run today
49// could produce.
50
51const TEST_ACCOUNT = "test-account-id";
52
53// A name no current-generator run can produce for the ids used below.
54const DRIFTED = "old-algorithm-name-a1b2c3";
55
56const stack: Omit<StackSpec, "output"> = {
57 name: "my-stack",
58 stage: "dev",
59 resources: {},
60 bindings: {},
61 actions: {},
62};
63
64const credentials: CloudflareResolvedCredentials = {
65 type: "apiToken",
66 apiToken: Redacted.make("test-token"),
67 accountId: TEST_ACCOUNT,
68 source: { type: "stored" },
69};
70
71const env = Layer.mergeAll(
72 Layer.succeed(CloudflareEnvironment, Effect.succeed(credentials)),
73 Layer.succeed(Stack, stack),
74 Layer.succeed(Stage, stack.stage),
75 Layer.succeed(InstanceId, "0123456789abcdef0123456789abcdef"),
76 Layer.succeed(AlchemyContext, {
77 dotAlchemy: "/tmp/.alchemy-test",
78 dev: false,
79 adopt: false,
80 }),
81 // The remaining layers only satisfy the provider layers' type-level
82 // requirements (reconcile/read need clients); diff never touches them.
83 Layer.succeed(
84 Credentials,
85 Effect.succeed(apiTokenCredentials({ apiToken: "test-token" })),
86 ),
87 Layer.sync(ArtifactStore, createArtifactStore),
88 Layer.succeed(
89 LocalRuntimeState,
90 LocalRuntimeState.of({
91 queues: MutableHashMap.empty(),
92 queueConsumers: MutableHashMap.empty(),
93 workerRestarts: MutableHashMap.empty(),
94 }),
95 ),
96 NodeServices.layer,
97 FetchHttpClient.layer,
98);
99
100const diffInput = <Olds, News, Output>(
101 id: string,
102 olds: Olds,
103 news: News,
104 output: Output,
105) => ({
106 id,
107 fqn: id,
108 instanceId: "0123456789abcdef0123456789abcdef",
109 olds: olds as never,
110 news: news as never,
111 output: output as never,
112 oldBindings: [] as never,
113 newBindings: [] as never,
114});
115
116describe(
117 "engine-owned names: generator drift never replaces",
118 { tags: ["unit", "provider:cloudflare", "local"] },
119 () => {
120 it.effect(
121 "D1 Database: drifted auto-generated name does not replace",
122 () =>
123 Effect.gen(function* () {
124 const provider = yield* Provider<Database>("Cloudflare.D1Database");
125 const result = yield* provider.diff!(
126 diffInput(
127 "Db",
128 {},
129 {},
130 {
131 databaseId: "11111111-2222-3333-4444-555555555555",
132 databaseName: DRIFTED,
133 accountId: TEST_ACCOUNT,
134 },
135 ),
136 );
137 expect(result?.action).not.toBe("replace");
138 }).pipe(Effect.provide(DatabaseProvider()), Effect.provide(env)),
139 { tags: ["provider:cloudflare:d1"] },
140 );
141
142 it.effect(
143 "D1 Database: explicit user rename still replaces",
144 () =>
145 Effect.gen(function* () {
146 const provider = yield* Provider<Database>("Cloudflare.D1Database");
147 const result = yield* provider.diff!(
148 diffInput(
149 "Db",
150 {},
151 { name: "explicit-new-name" },
152 {
153 databaseId: "11111111-2222-3333-4444-555555555555",
154 databaseName: DRIFTED,
155 accountId: TEST_ACCOUNT,
156 },
157 ),
158 );
159 expect(result?.action).toBe("replace");
160 }).pipe(Effect.provide(DatabaseProvider()), Effect.provide(env)),
161 { tags: ["provider:cloudflare:d1"] },
162 );
163
164 it.effect(
165 "D1 Database: explicit name equal to deployed name does not replace",
166 () =>
167 Effect.gen(function* () {
168 const provider = yield* Provider<Database>("Cloudflare.D1Database");
169 const result = yield* provider.diff!(
170 diffInput(
171 "Db",
172 { name: DRIFTED },
173 { name: DRIFTED },
174 {
175 databaseId: "11111111-2222-3333-4444-555555555555",
176 databaseName: DRIFTED,
177 accountId: TEST_ACCOUNT,
178 },
179 ),
180 );
181 expect(result?.action).not.toBe("replace");
182 }).pipe(Effect.provide(DatabaseProvider()), Effect.provide(env)),
183 { tags: ["provider:cloudflare:d1"] },
184 );
185
186 it.effect(
187 "R2 Bucket: drifted auto-generated name does not replace",
188 () =>
189 Effect.gen(function* () {
190 const provider = yield* Provider<Bucket>("Cloudflare.R2.Bucket");
191 const result = yield* provider.diff!(
192 diffInput(
193 "Files",
194 {},
195 {},
196 { bucketName: DRIFTED, accountId: TEST_ACCOUNT },
197 ),
198 );
199 expect(result?.action).not.toBe("replace");
200 }).pipe(Effect.provide(BucketProvider()), Effect.provide(env)),
201 { tags: ["provider:cloudflare:r2"] },
202 );
203
204 it.effect(
205 "R2 Bucket: explicit user rename still replaces",
206 () =>
207 Effect.gen(function* () {
208 const provider = yield* Provider<Bucket>("Cloudflare.R2.Bucket");
209 const result = yield* provider.diff!(
210 diffInput(
211 "Files",
212 {},
213 { name: "explicit-new-bucket" },
214 { bucketName: DRIFTED, accountId: TEST_ACCOUNT },
215 ),
216 );
217 expect(result?.action).toBe("replace");
218 }).pipe(Effect.provide(BucketProvider()), Effect.provide(env)),
219 { tags: ["provider:cloudflare:r2"] },
220 );
221
222 it.effect(
223 "Queue: drifted auto-generated name does not replace",
224 () =>
225 Effect.gen(function* () {
226 const provider = yield* Provider<Queue>("Cloudflare.Queues.Queue");
227 const result = yield* provider.diff!(
228 diffInput(
229 "Jobs",
230 {},
231 {},
232 {
233 queueId: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
234 queueName: DRIFTED,
235 accountId: TEST_ACCOUNT,
236 },
237 ),
238 );
239 expect(result?.action).not.toBe("replace");
240 }).pipe(Effect.provide(QueueProvider()), Effect.provide(env)),
241 { tags: ["provider:cloudflare:queue"] },
242 );
243
244 it.effect(
245 "Vectorize Index: drifted auto-generated name does not replace",
246 () =>
247 Effect.gen(function* () {
248 const provider = yield* Provider<Index>("Cloudflare.VectorizeIndex");
249 const result = yield* provider.diff!(
250 diffInput(
251 "Vectors",
252 {},
253 {},
254 { indexName: DRIFTED, accountId: TEST_ACCOUNT },
255 ),
256 );
257 expect(result?.action).not.toBe("replace");
258 }).pipe(Effect.provide(IndexProvider()), Effect.provide(env)),
259 { tags: ["provider:cloudflare:vectorize"] },
260 );
261
262 it.effect(
263 "KV Namespace: drifted auto-generated title does not rename",
264 () =>
265 Effect.gen(function* () {
266 const provider = yield* Provider<Namespace>(
267 "Cloudflare.KV.Namespace",
268 );
269 const result = yield* provider.diff!(
270 diffInput(
271 "Cache",
272 {},
273 {},
274 {
275 namespaceId: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
276 title: DRIFTED,
277 accountId: TEST_ACCOUNT,
278 },
279 ),
280 );
281 expect(result).toBeUndefined();
282 }).pipe(Effect.provide(NamespaceProvider()), Effect.provide(env)),
283 { tags: ["provider:cloudflare:kv"] },
284 );
285
286 it.effect(
287 "Hyperdrive Connection: drifted auto-generated name does not replace",
288 () =>
289 Effect.gen(function* () {
290 const provider = yield* Provider<Connection>("Cloudflare.Hyperdrive");
291 const result = yield* provider.diff!(
292 diffInput(
293 "Pg",
294 {},
295 {},
296 {
297 hyperdriveId: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
298 name: DRIFTED,
299 accountId: TEST_ACCOUNT,
300 },
301 ),
302 );
303 expect(result?.action).not.toBe("replace");
304 }).pipe(Effect.provide(ConnectionProvider()), Effect.provide(env)),
305 { tags: ["provider:cloudflare:hyperdrive"] },
306 );
307 },
308);