1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import * as Provider from "@/Provider";
4import * as Test from "@/Test/Alchemy";
5import { poll } from "@/Util/poll.ts";
6import * as flagship from "@distilled.cloud/cloudflare/flagship";
7import { expect } from "alchemy-test";
8import * as Data from "effect/Data";
9import * as Effect from "effect/Effect";
10import { MinimumLogLevel } from "effect/References";
11import * as Schedule from "effect/Schedule";
12
13const { test } = Test.make({ providers: Cloudflare.providers() });
14
15const logLevel = Effect.provideService(
16 MinimumLogLevel,
17 process.env.DEBUG ? "Debug" : "Info",
18);
19
20class FlagStillExists extends Data.TaggedError("FlagStillExists") {}
21
22// A deleted flag surfaces as `FlagshipFlagNotFound` (or, when the parent app
23// was destroyed too, `FlagshipAppNotFound`) — either way it's gone.
24const expectFlagGone = (accountId: string, appId: string, flagKey: string) =>
25 flagship.getAppFlag({ accountId, appId, flagKey }).pipe(
26 Effect.flatMap(() => Effect.fail(new FlagStillExists())),
27 Effect.retry({
28 while: (e): e is FlagStillExists => e instanceof FlagStillExists,
29 schedule: Schedule.max([
30 Schedule.exponential("250 millis"),
31 Schedule.recurs(10),
32 ]),
33 }),
34 Effect.catchTag(
35 ["FlagshipFlagNotFound", "FlagshipAppNotFound"],
36 () => Effect.void,
37 ),
38 );
39
40test.provider(
41 "create, update, delete a flag in an app",
42 (stack) =>
43 Effect.gen(function* () {
44 const { accountId } = yield* yield* CloudflareEnvironment;
45
46 yield* stack.destroy();
47
48 const initial = yield* stack.deploy(
49 Effect.gen(function* () {
50 const app = yield* Cloudflare.Flagship.App("FlagApp", {
51 name: "alchemy-test-flagship-flags",
52 });
53 const flag = yield* Cloudflare.Flagship.Flag("Flag", {
54 appId: app.appId,
55 key: "alchemy-test-flag",
56 defaultVariation: "off",
57 variations: { off: false, on: true },
58 });
59 return { app, flag };
60 }),
61 );
62
63 expect(initial.flag.accountId).toEqual(accountId);
64 expect(initial.flag.appId).toEqual(initial.app.appId);
65 expect(initial.flag.key).toEqual("alchemy-test-flag");
66 expect(initial.flag.enabled).toBe(true);
67 expect(initial.flag.defaultVariation).toEqual("off");
68 expect(initial.flag.variations).toEqual({ off: false, on: true });
69 expect(initial.flag.rules).toEqual([]);
70
71 // Verify out-of-band via the API.
72 const live = yield* flagship.getAppFlag({
73 accountId,
74 appId: initial.app.appId,
75 flagKey: "alchemy-test-flag",
76 });
77 expect(live.enabled).toBe(true);
78 expect(live.defaultVariation).toEqual("off");
79 expect(live.type).toEqual("boolean");
80
81 // Update mutable props in place — same key, same app.
82 const updated = yield* stack.deploy(
83 Effect.gen(function* () {
84 const app = yield* Cloudflare.Flagship.App("FlagApp", {
85 name: "alchemy-test-flagship-flags",
86 });
87 const flag = yield* Cloudflare.Flagship.Flag("Flag", {
88 appId: app.appId,
89 key: "alchemy-test-flag",
90 enabled: false,
91 defaultVariation: "on",
92 variations: { off: false, on: true },
93 description: "managed by alchemy",
94 rules: [
95 {
96 priority: 1,
97 conditions: [
98 { attribute: "country", operator: "equals", value: "US" },
99 ],
100 serveVariation: "on",
101 rollout: { percentage: 50 },
102 },
103 ],
104 });
105 return { app, flag };
106 }),
107 );
108
109 expect(updated.flag.key).toEqual("alchemy-test-flag");
110 expect(updated.flag.appId).toEqual(initial.app.appId);
111 expect(updated.flag.enabled).toBe(false);
112 expect(updated.flag.defaultVariation).toEqual("on");
113 expect(updated.flag.description).toEqual("managed by alchemy");
114 expect(updated.flag.rules).toEqual([
115 {
116 priority: 1,
117 conditions: [
118 { attribute: "country", operator: "equals", value: "US" },
119 ],
120 serveVariation: "on",
121 rollout: { percentage: 50 },
122 },
123 ]);
124
125 const liveUpdated = yield* flagship.getAppFlag({
126 accountId,
127 appId: initial.app.appId,
128 flagKey: "alchemy-test-flag",
129 });
130 expect(liveUpdated.enabled).toBe(false);
131 expect(liveUpdated.defaultVariation).toEqual("on");
132 expect(liveUpdated.rules).toHaveLength(1);
133
134 // Redeploying identical props is a no-op.
135 const noop = yield* stack.deploy(
136 Effect.gen(function* () {
137 const app = yield* Cloudflare.Flagship.App("FlagApp", {
138 name: "alchemy-test-flagship-flags",
139 });
140 const flag = yield* Cloudflare.Flagship.Flag("Flag", {
141 appId: app.appId,
142 key: "alchemy-test-flag",
143 enabled: false,
144 defaultVariation: "on",
145 variations: { off: false, on: true },
146 description: "managed by alchemy",
147 rules: [
148 {
149 priority: 1,
150 conditions: [
151 { attribute: "country", operator: "equals", value: "US" },
152 ],
153 serveVariation: "on",
154 rollout: { percentage: 50 },
155 },
156 ],
157 });
158 return { app, flag };
159 }),
160 );
161 expect(noop.flag.updatedAt).toEqual(updated.flag.updatedAt);
162
163 yield* stack.destroy();
164
165 yield* expectFlagGone(accountId, initial.app.appId, "alchemy-test-flag");
166 }).pipe(logLevel),
167 { tags: ["provider:cloudflare", "provider:cloudflare:flagship", "live"] },
168);
169
170test.provider(
171 "replaces the flag when the key changes",
172 (stack) =>
173 Effect.gen(function* () {
174 const { accountId } = yield* yield* CloudflareEnvironment;
175
176 yield* stack.destroy();
177
178 const initial = yield* stack.deploy(
179 Effect.gen(function* () {
180 const app = yield* Cloudflare.Flagship.App("ReplaceApp", {
181 name: "alchemy-test-flagship-replace",
182 });
183 const flag = yield* Cloudflare.Flagship.Flag("ReplaceFlag", {
184 appId: app.appId,
185 key: "alchemy-test-flag-a",
186 defaultVariation: "off",
187 variations: { off: false, on: true },
188 });
189 return { app, flag };
190 }),
191 );
192 expect(initial.flag.key).toEqual("alchemy-test-flag-a");
193
194 // Changing the key is a replacement: a new flag is created and the old
195 // one is deleted.
196 const replaced = yield* stack.deploy(
197 Effect.gen(function* () {
198 const app = yield* Cloudflare.Flagship.App("ReplaceApp", {
199 name: "alchemy-test-flagship-replace",
200 });
201 const flag = yield* Cloudflare.Flagship.Flag("ReplaceFlag", {
202 appId: app.appId,
203 key: "alchemy-test-flag-b",
204 defaultVariation: "off",
205 variations: { off: false, on: true },
206 });
207 return { app, flag };
208 }),
209 );
210
211 expect(replaced.flag.key).toEqual("alchemy-test-flag-b");
212 yield* expectFlagGone(
213 accountId,
214 initial.app.appId,
215 "alchemy-test-flag-a",
216 );
217
218 yield* stack.destroy();
219
220 yield* expectFlagGone(
221 accountId,
222 initial.app.appId,
223 "alchemy-test-flag-b",
224 );
225 }).pipe(logLevel),
226 { tags: ["provider:cloudflare", "provider:cloudflare:flagship", "live"] },
227);
228
229test.provider(
230 "recreates a flag after out-of-band delete",
231 (stack) =>
232 Effect.gen(function* () {
233 const { accountId } = yield* yield* CloudflareEnvironment;
234
235 yield* stack.destroy();
236
237 const initial = yield* stack.deploy(
238 Effect.gen(function* () {
239 const app = yield* Cloudflare.Flagship.App("HealApp", {
240 name: "alchemy-test-flagship-heal-flag",
241 });
242 const flag = yield* Cloudflare.Flagship.Flag("HealFlag", {
243 appId: app.appId,
244 key: "alchemy-test-flag-heal",
245 defaultVariation: "off",
246 variations: { off: false, on: true },
247 });
248 return { app, flag };
249 }),
250 );
251
252 // Delete the flag out-of-band. A redeploy with identical props is a
253 // planner no-op, so change a prop to force reconcile — it must observe
254 // the flag as missing and recreate it instead of failing on a 404.
255 yield* flagship.deleteAppFlag({
256 accountId,
257 appId: initial.app.appId,
258 flagKey: "alchemy-test-flag-heal",
259 });
260
261 const healed = yield* stack.deploy(
262 Effect.gen(function* () {
263 const app = yield* Cloudflare.Flagship.App("HealApp", {
264 name: "alchemy-test-flagship-heal-flag",
265 });
266 const flag = yield* Cloudflare.Flagship.Flag("HealFlag", {
267 appId: app.appId,
268 key: "alchemy-test-flag-heal",
269 enabled: false,
270 defaultVariation: "off",
271 variations: { off: false, on: true },
272 });
273 return { app, flag };
274 }),
275 );
276
277 expect(healed.flag.key).toEqual("alchemy-test-flag-heal");
278 expect(healed.flag.enabled).toBe(false);
279
280 const live = yield* flagship.getAppFlag({
281 accountId,
282 appId: initial.app.appId,
283 flagKey: "alchemy-test-flag-heal",
284 });
285 expect(live.enabled).toBe(false);
286
287 yield* stack.destroy();
288
289 yield* expectFlagGone(
290 accountId,
291 initial.app.appId,
292 "alchemy-test-flag-heal",
293 );
294 }).pipe(logLevel),
295 { tags: ["provider:cloudflare", "provider:cloudflare:flagship", "live"] },
296);
297
298test.provider(
299 "list enumerates the deployed flag",
300 (stack) =>
301 Effect.gen(function* () {
302 yield* stack.destroy();
303
304 const deployed = yield* stack.deploy(
305 Effect.gen(function* () {
306 const app = yield* Cloudflare.Flagship.App("ListApp", {
307 name: "alchemy-test-flagship-list",
308 });
309 const flag = yield* Cloudflare.Flagship.Flag("ListFlag", {
310 appId: app.appId,
311 key: "alchemy-test-flag-list",
312 defaultVariation: "off",
313 variations: { off: false, on: true },
314 });
315 return { app, flag };
316 }),
317 );
318
319 const provider = yield* Provider.findProvider(Cloudflare.Flagship.Flag);
320
321 // The flag itself is readable immediately (see the other tests' direct
322 // `getAppFlag` right after deploy). What lags is the account-wide
323 // `list()` path: the provider has no account-wide flag enumeration, so it
324 // goes `listApps -> listAppFlags`, and a freshly-created app appears in
325 // the account-wide `listApps` slower than the flag does under its own
326 // parent. Poll the full path until that app-list consistency catches up.
327 const all = yield* poll({
328 description: "list() includes the deployed flag",
329 effect: provider.list(),
330 predicate: (all) =>
331 all.some(
332 (f) =>
333 f.appId === deployed.app.appId && f.key === deployed.flag.key,
334 ),
335 schedule: Schedule.max([
336 Schedule.spaced("3 seconds"),
337 Schedule.recurs(30),
338 ]),
339 });
340
341 expect(
342 all.some(
343 (f) => f.appId === deployed.app.appId && f.key === deployed.flag.key,
344 ),
345 ).toBe(true);
346
347 yield* stack.destroy();
348 }).pipe(logLevel),
349 // The `listApps` consistency poll above is bounded at ~90s (30 x 3s) plus
350 // per-iteration `list()` latency, on top of two deploys; size the test over
351 // that bounded worst case rather than the 120s default.
352 {
353 tags: ["provider:cloudflare", "provider:cloudflare:flagship", "live"],
354 timeout: 180_000,
355 },
356);