1import * as flagship from "@distilled.cloud/cloudflare/flagship";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5import { deepEqual, isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
10import type { Providers } from "../Providers.ts";
11
12const TypeId = "Cloudflare.Flagship.Flag" as const;
13type TypeId = typeof TypeId;
14
15/**
16 * Comparison operator applied to a targeting condition.
17 */
18export type FlagConditionOperator =
19 | "equals"
20 | "not_equals"
21 | "greater_than"
22 | "less_than"
23 | "greater_than_or_equals"
24 | "less_than_or_equals"
25 | "contains"
26 | "starts_with"
27 | "ends_with"
28 | "in"
29 | "not_in";
30
31/**
32 * A single targeting condition: either a flat attribute comparison or a
33 * nested group of clauses combined with AND/OR.
34 */
35/**
36 * Value a flat condition compares against — a scalar, a JSON object, or a
37 * list (for `in` / `not_in`).
38 */
39export type FlagConditionValue =
40 | string
41 | number
42 | boolean
43 | { [key: string]: unknown }
44 | unknown[];
45
46export type FlagCondition =
47 | {
48 /**
49 * Evaluation-context attribute to match on (e.g. `country`, `userId`).
50 */
51 attribute: string;
52 /**
53 * Comparison operator.
54 */
55 operator: FlagConditionOperator;
56 /**
57 * Value to compare against.
58 */
59 value: FlagConditionValue;
60 }
61 | {
62 /**
63 * Nested conditions combined with `logicalOperator`.
64 */
65 clauses: FlagCondition[];
66 /**
67 * How the clauses combine.
68 */
69 logicalOperator: "AND" | "OR";
70 };
71
72/**
73 * Percentage rollout applied after a rule's conditions match.
74 */
75export type FlagRollout = {
76 /**
77 * Percentage of matching contexts served the rule's variation (0-100).
78 */
79 percentage: number;
80 /**
81 * Context attribute used for rollout bucketing.
82 * @default the context's targeting key
83 */
84 attribute?: string;
85};
86
87/**
88 * A targeting rule. Rules are evaluated in ascending `priority`; the first
89 * matching rule wins.
90 */
91export type FlagRule = {
92 /**
93 * Conditions that must all match for the rule to apply.
94 */
95 conditions: FlagCondition[];
96 /**
97 * Evaluation order — lower runs first.
98 */
99 priority: number;
100 /**
101 * Variation (key in `variations`) served when the rule matches.
102 */
103 serveVariation: string;
104 /**
105 * Optional percentage rollout applied after the conditions match.
106 */
107 rollout?: FlagRollout;
108};
109
110/**
111 * Value type of a flag's variations.
112 */
113export type FlagType = "boolean" | "string" | "number" | "json";
114
115export type FlagProps = {
116 /**
117 * The Flagship app the flag belongs to. Changing the app triggers a
118 * replacement.
119 */
120 appId: string;
121 /**
122 * Unique flag key within the app — used in all evaluation and SDK calls.
123 * Changing the key triggers a replacement. If omitted, a unique key is
124 * generated from the app, stage, and logical ID.
125 * @default ${app}-${stage}-${id}
126 */
127 key?: string;
128 /**
129 * When false, the flag bypasses all rules and always serves
130 * `defaultVariation`.
131 * @default true
132 */
133 enabled?: boolean;
134 /**
135 * Variation served when no rule matches or the flag is disabled. Must be a
136 * key in `variations`.
137 */
138 defaultVariation: string;
139 /**
140 * Map of variation name to value. All values must be the same type
141 * (boolean, string, number, or JSON object/array). Each serialized value
142 * must be 10KB or smaller.
143 */
144 variations: Record<string, unknown>;
145 /**
146 * Targeting rules evaluated in ascending `priority`; the first matching
147 * rule wins. An empty array means the flag always serves
148 * `defaultVariation`.
149 * @default []
150 */
151 rules?: FlagRule[];
152 /**
153 * Human readable description of the flag.
154 */
155 description?: string;
156 /**
157 * Value type of the flag's variations. Inferred from the variation values
158 * on write, so it can usually be omitted.
159 */
160 type?: FlagType;
161};
162
163export type FlagAttributes = {
164 /**
165 * The Flagship app the flag belongs to.
166 */
167 appId: string;
168 /**
169 * The Cloudflare account the flag belongs to.
170 */
171 accountId: string;
172 /**
173 * Unique flag key within the app.
174 */
175 key: string;
176 /**
177 * Whether the flag's rules are evaluated.
178 */
179 enabled: boolean;
180 /**
181 * Variation served when no rule matches or the flag is disabled.
182 */
183 defaultVariation: string;
184 /**
185 * Map of variation name to value.
186 */
187 variations: Record<string, unknown>;
188 /**
189 * Targeting rules.
190 */
191 rules: FlagRule[];
192 /**
193 * Human readable description of the flag.
194 */
195 description: string | undefined;
196 /**
197 * Value type of the flag's variations, as inferred by Cloudflare.
198 */
199 type: FlagType | undefined;
200 /**
201 * When the flag was last modified.
202 */
203 updatedAt: string | undefined;
204 /**
205 * Email of the actor who last modified the flag, or `edge-gateway` for
206 * gateway-authenticated changes.
207 */
208 updatedBy: string | undefined;
209};
210
211export type Flag = Resource<
212 TypeId,
213 FlagProps,
214 FlagAttributes,
215 never,
216 Providers
217>;
218
219/**
220 * A feature flag in a Cloudflare Flagship app.
221 *
222 * A flag maps a key to a set of variations plus targeting rules. Workers
223 * evaluate flags through the `Flagship` binding (or the REST evaluate
224 * endpoint); changing variations, rules, enablement, or the default
225 * variation takes effect without redeploying code. Everything except the
226 * flag key and the parent app is mutable in place.
227 * ### Creating a Flag
228 * **Example:** Boolean flag
229 * ```typescript
230 * const app = yield* Cloudflare.Flagship.App("Flags", {});
231 *
232 * const flag = yield* Cloudflare.Flagship.Flag("NewCheckout", {
233 * appId: app.appId,
234 * key: "new-checkout",
235 * defaultVariation: "off",
236 * variations: { off: false, on: true },
237 * });
238 * ```
239 *
240 * **Example:** String flag with multiple variations
241 * ```typescript
242 * const flag = yield* Cloudflare.Flagship.Flag("CheckoutFlow", {
243 * appId: app.appId,
244 * key: "checkout-flow",
245 * defaultVariation: "v1",
246 * variations: { v1: "classic", v2: "express", v3: "one-click" },
247 * });
248 * ```
249 *
250 * ### Targeting Rules
251 * **Example:** Serve a variation to a specific country
252 * ```typescript
253 * const flag = yield* Cloudflare.Flagship.Flag("DarkMode", {
254 * appId: app.appId,
255 * key: "dark-mode",
256 * defaultVariation: "off",
257 * variations: { off: false, on: true },
258 * rules: [
259 * {
260 * priority: 1,
261 * conditions: [
262 * { attribute: "country", operator: "equals", value: "US" },
263 * ],
264 * serveVariation: "on",
265 * },
266 * ],
267 * });
268 * ```
269 *
270 * **Example:** Percentage rollout
271 * ```typescript
272 * const flag = yield* Cloudflare.Flagship.Flag("NewSearch", {
273 * appId: app.appId,
274 * key: "new-search",
275 * defaultVariation: "off",
276 * variations: { off: false, on: true },
277 * rules: [
278 * {
279 * priority: 1,
280 * conditions: [],
281 * serveVariation: "on",
282 * rollout: { percentage: 25 },
283 * },
284 * ],
285 * });
286 * ```
287 *
288 * ### Toggling a Flag
289 * **Example:** Disable a flag without removing its rules
290 * ```typescript
291 * const flag = yield* Cloudflare.Flagship.Flag("NewCheckout", {
292 * appId: app.appId,
293 * key: "new-checkout",
294 * enabled: false,
295 * defaultVariation: "off",
296 * variations: { off: false, on: true },
297 * });
298 * ```
299 *
300 * @see https://developers.cloudflare.com/flagship/
301 * @see https://developers.cloudflare.com/api/resources/flagship/
302 *
303 * @resource
304 * @product Flagship
305 * @category Developer Platform
306 */
307export const Flag = Resource<Flag>(TypeId);
308
309/**
310 * Returns true if the given value is a Flagship Flag resource.
311 */
312export const isFlag = (value: unknown): value is Flag =>
313 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
314
315export const FlagProvider = () =>
316 Provider.succeed(Flag, {
317 stables: ["appId", "accountId", "key"],
318 diff: Effect.fn(function* ({ olds, news, output }) {
319 if (!isResolved(news)) return undefined;
320 const { accountId } = yield* yield* CloudflareEnvironment;
321 if ((output?.accountId ?? accountId) !== accountId) {
322 return { action: "replace" } as const;
323 }
324 // The app is a path parameter — a flag cannot move between apps in
325 // place. By diff time both sides are resolved strings.
326 const oldAppId = output?.appId ?? olds?.appId;
327 if (
328 typeof oldAppId === "string" &&
329 typeof news.appId === "string" &&
330 oldAppId !== news.appId
331 ) {
332 return { action: "replace" } as const;
333 }
334 // The key is the flag's identity within the app.
335 const oldKey = output?.key ?? olds?.key;
336 if (
337 typeof oldKey === "string" &&
338 typeof news.key === "string" &&
339 oldKey !== news.key
340 ) {
341 return { action: "replace" } as const;
342 }
343 return undefined;
344 }),
345 read: Effect.fn(function* ({ id, olds, output }) {
346 const { accountId } = yield* yield* CloudflareEnvironment;
347 const acct = output?.accountId ?? accountId;
348 const appId = output?.appId ?? (olds?.appId as string | undefined);
349 if (appId === undefined) return undefined;
350
351 // The key is deterministic (explicit or generated from the logical
352 // ID), so a cold read and a warm read are the same lookup.
353 const key = output?.key ?? (yield* createFlagKey(id, olds?.key));
354 const observed = yield* getFlag(acct, appId, key);
355 return observed ? toAttributes(observed, acct, appId) : undefined;
356 }),
357 reconcile: Effect.fn(function* ({ id, news, output }) {
358 const { accountId } = yield* yield* CloudflareEnvironment;
359 const appId = news.appId as string;
360 const key = yield* createFlagKey(id, news.key);
361 const desired = {
362 enabled: news.enabled ?? true,
363 defaultVariation: news.defaultVariation,
364 variations: news.variations,
365 rules: news.rules ?? [],
366 description: news.description,
367 };
368
369 // Observe — flag identity is (appId, key); a missing flag falls
370 // through and we recreate.
371 const observed = yield* getFlag(
372 output?.accountId ?? accountId,
373 appId,
374 key,
375 );
376
377 if (!observed) {
378 // Ensure — greenfield (or out-of-band delete). A concurrent create
379 // of the same key surfaces as FlagshipFlagAlreadyExists; treat it
380 // as a race and converge via the update path below.
381 const created = yield* flagship
382 .createAppFlag({
383 accountId,
384 appId,
385 key,
386 ...desired,
387 type: news.type,
388 })
389 .pipe(
390 Effect.catchTag("FlagshipFlagAlreadyExists", () =>
391 Effect.succeed(undefined),
392 ),
393 );
394 if (created) {
395 return toAttributes(created, accountId, appId);
396 }
397 }
398
399 // Sync — diff observed cloud state against desired; the update API is
400 // a PUT that takes the full body, so send everything, but skip the
401 // call entirely on a no-op.
402 const live = observed ?? (yield* getFlag(accountId, appId, key));
403 if (live) {
404 const observedShape = {
405 enabled: live.enabled,
406 defaultVariation: live.defaultVariation,
407 variations: live.variations,
408 rules: normalizeRules(live.rules),
409 description: live.description ?? undefined,
410 };
411 const desiredShape = {
412 ...desired,
413 rules: normalizeRules(desired.rules),
414 };
415 if (deepEqual(observedShape, desiredShape)) {
416 return toAttributes(live, accountId, appId);
417 }
418 }
419 const updated = yield* flagship.updateAppFlag({
420 accountId,
421 appId,
422 flagKey: key,
423 key,
424 ...desired,
425 type: news.type,
426 });
427 return toAttributes(updated, accountId, appId);
428 }),
429 delete: Effect.fn(function* ({ output }) {
430 yield* flagship
431 .deleteAppFlag({
432 accountId: output.accountId,
433 appId: output.appId,
434 flagKey: output.key,
435 })
436 // A missing flag or a missing parent app both mean it's already
437 // gone.
438 .pipe(
439 Effect.catchTag(
440 ["FlagshipFlagNotFound", "FlagshipAppNotFound"],
441 () => Effect.void,
442 ),
443 );
444 }),
445 // Flags are sub-resources keyed by (accountId, appId, key). There is no
446 // account-wide flag enumeration, so enumerate every Flagship app first,
447 // then fan out the per-app flag list and flatten.
448 list: Effect.fn(function* () {
449 const { accountId } = yield* yield* CloudflareEnvironment;
450 const apps = yield* flagship.listApps.pages({ accountId }).pipe(
451 Stream.runCollect,
452 Effect.map((chunk) =>
453 Array.from(chunk).flatMap((page) => page.result ?? []),
454 ),
455 );
456 const rows = yield* Effect.forEach(
457 apps,
458 (app) =>
459 flagship.listAppFlags.pages({ accountId, appId: app.id }).pipe(
460 Stream.runCollect,
461 Effect.map((chunk) =>
462 Array.from(chunk).flatMap((page) =>
463 (page.result ?? []).map((flag) =>
464 toAttributes(flag, accountId, app.id),
465 ),
466 ),
467 ),
468 // The parent app can be deleted between enumeration and the
469 // per-app flag list; treat a gone app as having no flags.
470 Effect.catchTag("FlagshipAppNotFound", () =>
471 Effect.succeed<FlagAttributes[]>([]),
472 ),
473 ),
474 { concurrency: 10 },
475 );
476 return rows.flat();
477 }),
478 });
479
480/**
481 * Read a flag by key, mapping "gone" (`FlagshipFlagNotFound`, or a deleted
482 * parent app surfacing as `FlagshipAppNotFound`) to `undefined`.
483 */
484const getFlag = (accountId: string, appId: string, flagKey: string) =>
485 flagship
486 .getAppFlag({ accountId, appId, flagKey })
487 .pipe(
488 Effect.catchTag(["FlagshipFlagNotFound", "FlagshipAppNotFound"], () =>
489 Effect.succeed(undefined),
490 ),
491 );
492
493const createFlagKey = (id: string, key: string | undefined) =>
494 Effect.gen(function* () {
495 return key ?? (yield* createPhysicalName({ id, lowercase: true }));
496 });
497
498/**
499 * Wire rules carry readonly markers, open-union widening, and explicit
500 * nulls; strip them so rules diff structurally against the user's props.
501 */
502const normalizeRules = (rules: readonly WireRule[] | FlagRule[]): FlagRule[] =>
503 rules.map((rule) => ({
504 conditions: normalizeConditions(rule.conditions),
505 priority: rule.priority,
506 serveVariation: rule.serveVariation,
507 ...(rule.rollout
508 ? {
509 rollout: {
510 percentage: rule.rollout.percentage,
511 ...(rule.rollout.attribute != null
512 ? { attribute: rule.rollout.attribute }
513 : {}),
514 },
515 }
516 : {}),
517 }));
518
519type WireRule = {
520 conditions: readonly unknown[];
521 priority: number;
522 serveVariation: string;
523 rollout?: { percentage: number; attribute?: string | null } | null;
524};
525
526const normalizeConditions = (conditions: readonly unknown[]): FlagCondition[] =>
527 conditions.flatMap((condition): FlagCondition[] => {
528 if (Predicate.hasProperty(condition, "clauses")) {
529 const group = condition as {
530 clauses: readonly unknown[];
531 logicalOperator: string;
532 };
533 return [
534 {
535 clauses: normalizeConditions(group.clauses),
536 logicalOperator: group.logicalOperator as "AND" | "OR",
537 },
538 ];
539 }
540 if (Predicate.hasProperty(condition, "attribute")) {
541 const flat = condition as {
542 attribute: string;
543 operator: string;
544 value: FlagConditionValue;
545 };
546 return [
547 {
548 attribute: flat.attribute,
549 operator: flat.operator as FlagConditionOperator,
550 value: flat.value,
551 },
552 ];
553 }
554 return [];
555 });
556
557const toAttributes = (
558 flag:
559 | flagship.GetAppFlagResponse
560 | flagship.CreateAppFlagResponse
561 | flagship.UpdateAppFlagResponse
562 | flagship.ListAppFlagsResponse["result"][number],
563 accountId: string,
564 appId: string,
565): FlagAttributes => ({
566 appId,
567 accountId,
568 key: flag.key,
569 enabled: flag.enabled,
570 defaultVariation: flag.defaultVariation,
571 variations: flag.variations,
572 rules: normalizeRules(flag.rules),
573 description: flag.description ?? undefined,
574 type: (flag.type as FlagType | null | undefined) ?? undefined,
575 updatedAt: flag.updatedAt ?? undefined,
576 updatedBy: flag.updatedBy ?? undefined,
577});