1import type {
2 AddOnPlansResponseEdgesItemNode,
3 AddOnsResponseEdgesItemNode,
4 CreateAddOnResponseAddOn,
5} from "@distilled.cloud/fly-io/addons";
6import * as addons from "@distilled.cloud/fly-io/addons";
7import * as machines from "@distilled.cloud/fly-io/machines";
8import * as Data from "effect/Data";
9import * as Duration from "effect/Duration";
10import * as Effect from "effect/Effect";
11import * as Redacted from "effect/Redacted";
12import * as Result from "effect/Result";
13import * as Schedule from "effect/Schedule";
14import { Unowned } from "../AdoptPolicy.ts";
15import { deepEqual, isResolved } from "../Diff.ts";
16import * as Provider from "../Provider.ts";
17import { Resource } from "../Resource.ts";
18import { resolveOrgSlug } from "./Environment.ts";
19import {
20 createFlyAppName,
21 matchesAlchemyPhysicalName,
22 sanitizeFlyAppName,
23} from "./Metadata.ts";
24import type { Providers } from "./Providers.ts";
25
26export const DEFAULT_REDIS_REGION = "iad";
27export const REDIS_ADDON_TYPE = "upstash_redis";
28export const REDIS_PROVIDER = "upstash_redis";
29export const REDIS_URL_ENV = "REDIS_URL";
30
31export {
32 CommandError as RedisCommandError,
33 UrlMissing as RedisUrlMissing,
34} from "../Redis/index.ts";
35
36export interface RedisProps {
37 /**
38 * Upstash Redis database name. Globally unique in the org. If omitted,
39 * a unique name is generated from the stack, stage and logical ID.
40 * Changing it replaces the database.
41 */
42 name?: string;
43 /**
44 * Primary region (`iad`, `ord`, `sjc`, …). Fly cannot move a primary.
45 * Changing it replaces the database.
46 *
47 * @default "iad"
48 */
49 primaryRegion?: string;
50 /**
51 * Organization slug. Defaults to the current token's org. Changing it
52 * replaces the database.
53 */
54 orgSlug?: string;
55 /**
56 * Add-on plan id, name, or display name from `addOnPlans`. Default is
57 * the cheapest listed plan (free or pay-as-you-go). Fixed plans are
58 * billed. Default tests use the cheapest listed plan.
59 */
60 plan?: string;
61 /**
62 * Read-replica regions. Updated in place. Must not include the
63 * primary region.
64 */
65 readRegions?: string[];
66 /**
67 * Evict keys when memory is full (cache workload). Updated in place.
68 */
69 eviction?: boolean;
70 /**
71 * Automatically upgrade a fixed plan when hitting resource limits.
72 * Ignored on pay-as-you-go.
73 */
74 autoUpgrade?: boolean;
75 /**
76 * ProdPack add-on ($200/mo). Paid. Not enabled in default tests.
77 */
78 prodPack?: boolean;
79}
80
81export type Redis = Resource<
82 "Fly.Redis",
83 RedisProps,
84 {
85 /** Fly GraphQL add-on id. */
86 redisId: string;
87 /** Physical Upstash Redis name. */
88 name: string;
89 /** Primary region code. */
90 primaryRegion: string;
91 /** Observed read-replica regions. */
92 readRegions: string[];
93 /** Observed status (`ready`, `provisioning`, …). */
94 status: string | undefined;
95 /** Selected plan id, if the API returned one. */
96 planId: string | undefined;
97 /** Observed plan name (`Pay-as-you-go`, `Fixed 250MB`, …). */
98 planName: string | undefined;
99 /** Private 6PN address, if the API returned one. */
100 privateIp: string | undefined;
101 /** Organization slug. */
102 orgSlug: string | undefined;
103 /** Whether eviction is enabled. */
104 eviction: boolean | undefined;
105 /**
106 * Redacted Upstash connection URL. Bindings transport it to the
107 * runtime automatically. Service attachments also set `REDIS_URL`.
108 */
109 url: Redacted.Redacted<string> | undefined;
110 },
111 never,
112 Providers
113>;
114
115/**
116 * Managed Upstash Redis in a Fly org. Bind {@link ReadRedis},
117 * {@link WriteRedis}, or {@link ReadWriteRedis} on a {@link Service}.
118 * Alchemy transports the redacted `url` Output to the runtime client
119 * and also writes `REDIS_URL` as an App secret for compatibility.
120 * Redis is not reachable from CI — drive it over HTTP.
121 *
122 * @see https://fly.io/docs/upstash/redis/
123 *
124 * ### Create Redis
125 * Alchemy generates a unique name unless you pass one. Default region is
126 * `iad`. Default plan is the cheapest `addOnPlans` row (free or
127 * pay-as-you-go).
128 *
129 * **Example:** Generated name
130 * ```typescript
131 * const cache = yield* Fly.Redis("Cache");
132 * ```
133 *
134 * :::note
135 * Prefer omitting `name` in tests and CI so names stay unique and
136 * reclaimable.
137 * :::
138 *
139 * ### A stable name
140 * Pass `name` when you want a stable Upstash database name.
141 *
142 * **Example:** Explicit name
143 * ```typescript
144 * const cache = yield* Fly.Redis("Cache", {
145 * name: "my-cache",
146 * primaryRegion: "iad",
147 * });
148 * ```
149 *
150 * :::caution[Changing `name` replaces Redis]
151 * Fly cannot rename an add-on. Alchemy deletes the old database first,
152 * then creates the new one.
153 * :::
154 *
155 * :::caution[Changing `primaryRegion` replaces Redis]
156 * The primary region is immutable. The old database is deleted first
157 * because the name cannot exist twice.
158 * :::
159 *
160 * ### Bind from a Service
161 * Yield {@link ReadWriteRedis} (or {@link ReadRedis} / {@link WriteRedis})
162 * in Service init. Provide the matching `*Http` layer.
163 *
164 * **Example:** Read and write
165 * ```typescript
166 * import * as HttpServerResponse from "effect/http/HttpServerResponse";
167 *
168 * const Cache = Fly.Redis("Cache");
169 *
170 * export default class Api extends Fly.Service<Api>()(
171 * "Api",
172 * { app: Site, main: import.meta.url, port: 3000 },
173 * Effect.gen(function* () {
174 * const cache = yield* Fly.ReadWriteRedis(Cache);
175 * return {
176 * fetch: Effect.gen(function* () {
177 * yield* cache.set("marker", "hello");
178 * const value = yield* cache.get("marker");
179 * return HttpServerResponse.json({ value });
180 * }),
181 * };
182 * }).pipe(Effect.provide(Fly.ReadWriteRedisHttp)),
183 * ) {}
184 * ```
185 *
186 * ### Eviction
187 * Enable eviction for cache workloads. Updated in place.
188 *
189 * **Example:** Eviction
190 * ```typescript
191 * const cache = yield* Fly.Redis("Cache", {
192 * eviction: true,
193 * });
194 * ```
195 *
196 * ### Read replicas
197 * `readRegions` are extra replica regions. Updated in place.
198 *
199 * **Example:** Replica region
200 * ```typescript
201 * const cache = yield* Fly.Redis("Cache", {
202 * primaryRegion: "iad",
203 * readRegions: ["sjc"],
204 * });
205 * ```
206 *
207 * ### Plan
208 * Omit `plan` for the cheapest listed plan. Pass a plan id or display
209 * name to pin one. Fixed plans are billed.
210 *
211 * **Example:** Pin pay-as-you-go
212 * ```typescript
213 * const cache = yield* Fly.Redis("Cache", {
214 * plan: "Pay-as-you-go",
215 * });
216 * ```
217 *
218 * :::caution[Fixed plans are billed]
219 * Fixed plans are billed monthly. Prefer omitting `plan` unless you
220 * need a specific size.
221 * :::
222 *
223 * @resource
224 * @product Redis
225 */
226export const Redis = Resource<Redis>("Fly.Redis");
227
228export class RedisNotCreated extends Data.TaggedError("Fly.RedisNotCreated")<{
229 name: string;
230 errorMessage?: string;
231}> {}
232
233export class RedisPlanNotFound extends Data.TaggedError(
234 "Fly.RedisPlanNotFound",
235)<{
236 plan: string;
237}> {}
238
239export class RedisOrgMissing extends Data.TaggedError("Fly.RedisOrgMissing")<{
240 orgSlug: string;
241}> {}
242
243class RedisPending extends Data.TaggedError("Fly.RedisPending")<{
244 redisId: string;
245 status: string;
246}> {}
247
248type ObservedRedis = AddOnsResponseEdgesItemNode | CreateAddOnResponseAddOn;
249
250type RedisPlan = AddOnPlansResponseEdgesItemNode;
251
252const backoff = Schedule.min([
253 Schedule.exponential(Duration.millis(500), 1.5),
254 Schedule.spaced(Duration.seconds(5)),
255]);
256
257const unwrapSensitive = (
258 value: string | Redacted.Redacted<string> | null | undefined,
259): string | undefined => {
260 if (value == null) return undefined;
261 return Redacted.isRedacted(value) ? Redacted.value(value) : value;
262};
263
264const recordOf = (value: unknown): Record<string, unknown> => {
265 if (value != null && typeof value === "object" && !Array.isArray(value)) {
266 return { ...(value as Record<string, unknown>) };
267 }
268 return {};
269};
270
271const stringRecordOf = (value: unknown): Record<string, string> =>
272 Object.fromEntries(
273 Object.entries(recordOf(value)).flatMap(([key, item]) =>
274 typeof item === "string" && item.length > 0 ? [[key, item]] : [],
275 ),
276 );
277
278const evictionOf = (options: unknown): boolean | undefined => {
279 const value = recordOf(options).eviction;
280 return typeof value === "boolean" ? value : undefined;
281};
282
283const sorted = (regions: readonly string[] | null | undefined): string[] =>
284 [...(regions ?? [])].sort();
285
286const pendingStatus = (status: string | null | undefined): boolean => {
287 if (status == null || status.length === 0) return false;
288 const value = status.toLowerCase();
289 return (
290 value === "pending" ||
291 value === "provisioning" ||
292 value === "creating" ||
293 value === "launching"
294 );
295};
296
297const failedStatus = (status: string | null | undefined): boolean => {
298 if (status == null) return false;
299 const value = status.toLowerCase();
300 return value === "error" || value === "failed" || value === "destroyed";
301};
302
303const isLegacyPlanName = (plan: RedisPlan): boolean => {
304 const normalized = (plan.displayName ?? plan.name ?? "")
305 .toLowerCase()
306 .replaceAll(" ", "_");
307 return (
308 normalized === "pro_2k" ||
309 normalized === "pro_10k" ||
310 normalized === "starter" ||
311 normalized === "standard"
312 );
313};
314
315export const isFixedRedisPlan = (
316 plan: Pick<RedisPlan, "name" | "displayName">,
317): boolean => {
318 const display = (plan.displayName ?? "").toLowerCase();
319 const name = (plan.name ?? "").toLowerCase();
320 return display.startsWith("fixed ") || name.startsWith("flyio_fixed_");
321};
322
323const hasAlchemyMetadata = (metadata: unknown): boolean => {
324 const tags = stringRecordOf(metadata);
325 const stack = tags["alchemy::stack"] ?? tags["alchemy.stack"];
326 return stack !== undefined && stack.length > 0;
327};
328
329const isOwnedRedis = (row: ObservedRedis): boolean =>
330 matchesAlchemyPhysicalName(row.name ?? undefined) ||
331 hasAlchemyMetadata(row.metadata);
332
333const resolveName = (id: string, name: string | undefined, existing?: string) =>
334 Effect.gen(function* () {
335 if (name !== undefined) return sanitizeFlyAppName(name);
336 if (existing !== undefined) return existing;
337 return yield* createFlyAppName(id);
338 });
339
340const urlOf = (
341 row: ObservedRedis,
342 previous?: Redacted.Redacted<string>,
343): Redacted.Redacted<string> | undefined => {
344 const raw = unwrapSensitive(row.publicUrl);
345 if (raw !== undefined && raw.length > 0) return Redacted.make(raw);
346 return previous;
347};
348
349const toAttrs = (
350 row: ObservedRedis,
351 fallback: {
352 name: string;
353 primaryRegion: string;
354 orgSlug?: string;
355 planId?: string;
356 url?: Redacted.Redacted<string>;
357 },
358): Redis["Attributes"] => ({
359 redisId: row.id,
360 name: row.name ?? fallback.name,
361 primaryRegion: row.primaryRegion ?? fallback.primaryRegion,
362 readRegions: sorted(row.readRegions),
363 status: row.status ?? undefined,
364 planId:
365 "addOnPlan" in row
366 ? ((row.addOnPlan as { id?: string } | null | undefined)?.id ??
367 fallback.planId)
368 : fallback.planId,
369 planName: row.addOnPlanName ?? undefined,
370 privateIp: row.privateIp ?? undefined,
371 orgSlug:
372 "organization" in row
373 ? ((row.organization as { slug?: string | null } | undefined)?.slug ??
374 fallback.orgSlug)
375 : fallback.orgSlug,
376 eviction: evictionOf(row.options),
377 url: urlOf(row, fallback.url),
378});
379
380export const listRedisAddOns = Effect.fn(function* () {
381 const rows: AddOnsResponseEdgesItemNode[] = [];
382 let after: string | undefined;
383 for (let i = 0; i < 8; i++) {
384 const page = yield* addons.addOns({
385 type: REDIS_ADDON_TYPE,
386 first: 50,
387 after,
388 });
389 for (const edge of page.edges ?? []) {
390 if (edge?.node != null) rows.push(edge.node);
391 }
392 if (!page.pageInfo.hasNextPage) break;
393 after = page.pageInfo.endCursor ?? undefined;
394 if (after === undefined || after.length === 0) break;
395 }
396 return rows;
397});
398
399export const listRedisPlans = Effect.fn(function* () {
400 const rows: RedisPlan[] = [];
401 let after: string | undefined;
402 for (let i = 0; i < 4; i++) {
403 const page = yield* addons.addOnPlans({
404 type: REDIS_ADDON_TYPE,
405 first: 50,
406 after,
407 });
408 for (const edge of page.edges ?? []) {
409 if (edge?.node != null) rows.push(edge.node);
410 }
411 if (!page.pageInfo.hasNextPage) break;
412 after = page.pageInfo.endCursor ?? undefined;
413 if (after === undefined || after.length === 0) break;
414 }
415 return rows;
416});
417
418export const findRedisAddOn = (input: { id?: string; name?: string }) =>
419 listRedisAddOns().pipe(
420 Effect.map((rows) =>
421 rows.find((row) => {
422 if (input.id !== undefined && row.id === input.id) return true;
423 if (input.name !== undefined && row.name === input.name) return true;
424 return false;
425 }),
426 ),
427 );
428
429const cheapestPlan = (plans: RedisPlan[]) => {
430 const usable = plans.filter((plan) => !isLegacyPlanName(plan));
431 const pool = usable.length > 0 ? usable : plans;
432 if (pool.length === 0) return undefined;
433 const free = pool.filter((plan) => (plan.pricePerMonth ?? 0) === 0);
434 const ranked = (free.length > 0 ? free : pool).slice().sort((left, right) => {
435 const byPrice = (left.pricePerMonth ?? 0) - (right.pricePerMonth ?? 0);
436 if (byPrice !== 0) return byPrice;
437 return (left.displayName ?? left.name ?? "").localeCompare(
438 right.displayName ?? right.name ?? "",
439 );
440 });
441 const payg = ranked.find((plan) =>
442 /pay.?as.?you.?go|free/i.test(
443 `${plan.displayName ?? ""} ${plan.name ?? ""}`,
444 ),
445 );
446 return payg ?? ranked[0];
447};
448
449const resolvePlan = (
450 plan: string | undefined,
451 existingId: string | undefined,
452) =>
453 Effect.gen(function* () {
454 const plans = yield* listRedisPlans();
455 if (plan !== undefined) {
456 const found = plans.find(
457 (item) =>
458 item.id === plan || item.name === plan || item.displayName === plan,
459 );
460 if (found === undefined) {
461 return yield* new RedisPlanNotFound({ plan });
462 }
463 return found;
464 }
465 if (existingId !== undefined) {
466 const existing = plans.find((item) => item.id === existingId);
467 if (existing !== undefined) return existing;
468 }
469 const cheapest = cheapestPlan(plans);
470 if (cheapest === undefined) {
471 return yield* new RedisPlanNotFound({ plan: "default" });
472 }
473 return cheapest;
474 });
475
476const resolveOrganization = (orgSlug: string) =>
477 addons
478 .organization({ slug: orgSlug })
479 .pipe(Effect.catchTag("FlyIoParseError", () => Effect.succeed(undefined)));
480
481const ensureTos = (orgSlug: string, organizationId: string) =>
482 Effect.gen(function* () {
483 const agreed = yield* addons.agreedToProviderTos({
484 slug: orgSlug,
485 providerName: REDIS_PROVIDER,
486 });
487 if (agreed === true) return;
488 // Tokens without org-admin cannot write the ToS row. Create still
489 // succeeds when the org already agreed via flyctl.
490 yield* Effect.result(
491 addons.createExtensionTosAgreement({
492 input: {
493 addOnProviderName: REDIS_PROVIDER,
494 organizationId,
495 },
496 }),
497 );
498 });
499
500const waitUntilReady = (id: string, name: string) =>
501 findRedisAddOn({ id, name }).pipe(
502 Effect.flatMap(
503 (
504 row,
505 ): Effect.Effect<
506 AddOnsResponseEdgesItemNode,
507 RedisPending | RedisNotCreated
508 > => {
509 if (row === undefined) {
510 return Effect.fail(
511 new RedisPending({ redisId: id, status: "missing" }),
512 );
513 }
514 if (failedStatus(row.status)) {
515 return Effect.fail(
516 new RedisNotCreated({
517 name: row.name ?? name,
518 errorMessage: row.errorMessage ?? row.status ?? undefined,
519 }),
520 );
521 }
522 if (pendingStatus(row.status)) {
523 return Effect.fail(
524 new RedisPending({
525 redisId: id,
526 status: row.status ?? "provisioning",
527 }),
528 );
529 }
530 return Effect.succeed(row);
531 },
532 ),
533 Effect.retry({
534 while: (error) => error._tag === "Fly.RedisPending",
535 times: 10,
536 schedule: backoff,
537 }),
538 Effect.catchTag("Fly.RedisPending", () => findRedisAddOn({ id, name })),
539 );
540
541const waitUntilGone = (id: string, name: string) =>
542 findRedisAddOn({ id, name }).pipe(
543 Effect.map((row) => row === undefined),
544 Effect.repeat({
545 schedule: Schedule.spaced("2 seconds"),
546 until: (gone) => gone,
547 times: 10,
548 }),
549 );
550
551const desiredOptions = (
552 props: RedisProps,
553 plan: RedisPlan,
554 observed: unknown | undefined,
555): Record<string, unknown> => {
556 const options = recordOf(observed);
557 if (props.eviction !== undefined) options.eviction = props.eviction;
558 if (isFixedRedisPlan(plan)) {
559 if (props.autoUpgrade !== undefined) {
560 options.auto_upgrade = props.autoUpgrade;
561 }
562 } else {
563 delete options.auto_upgrade;
564 }
565 delete options.prod_pack;
566 return options;
567};
568
569class RedisSecretVersionMissing extends Data.TaggedError(
570 "Fly.RedisSecretVersionMissing",
571)<{ appName: string }> {}
572
573const redisSecretVersion = (
574 appName: string,
575 response: machines.AppSecretsUpdateResp | machines.SetAppSecretResponse,
576) => {
577 const version = response.version ?? response.Version;
578 return version !== undefined && Number.isSafeInteger(version) && version >= 0
579 ? Effect.succeed(version)
580 : Effect.fail(new RedisSecretVersionMissing({ appName }));
581};
582
583/**
584 * Write `REDIS_URL` onto an App from attached Redis add-on names.
585 * Called from {@link Service} reconcile so the secret exists before
586 * Machines boot. Returns the highest accepted secret-version floor, or
587 * `undefined` when no secret was written; this is not a vault snapshot.
588 */
589export const attachRedisSecrets = Effect.fn(function* (
590 appName: string,
591 attached: readonly { name: string; id?: string }[],
592) {
593 if (appName.length === 0 || attached.length === 0) return undefined;
594 const versions: number[] = [];
595 for (const item of attached) {
596 const name = item.name;
597 const id = item.id;
598 if (name.length === 0 && (id === undefined || id.length === 0)) continue;
599 const row = yield* findRedisAddOn({ id, name }).pipe(
600 Effect.flatMap((found) =>
601 found === undefined
602 ? Effect.fail(
603 new RedisPending({
604 redisId: id ?? "",
605 status: "missing",
606 }),
607 )
608 : Effect.succeed(found),
609 ),
610 Effect.retry({
611 while: (error) => error._tag === "Fly.RedisPending",
612 times: 8,
613 schedule: backoff,
614 }),
615 Effect.catchTag("Fly.RedisPending", () => findRedisAddOn({ id, name })),
616 );
617 if (row === undefined) {
618 return yield* new RedisPending({
619 redisId: id ?? name,
620 status: "missing",
621 });
622 }
623 let url = unwrapSensitive(row.publicUrl);
624 if ((url === undefined || url.length === 0) && row.id !== undefined) {
625 const detail = yield* addons
626 .addOn({ id: row.id })
627 .pipe(
628 Effect.catchTag("FlyIoParseError", () => Effect.succeed(undefined)),
629 );
630 url = unwrapSensitive(detail?.publicUrl);
631 }
632 if (url === undefined || url.length === 0) {
633 return yield* new RedisPending({
634 redisId: row.id ?? id ?? name,
635 status: "credentials missing",
636 });
637 }
638 const value = url;
639 const update = machines
640 .updateSecrets({
641 app_name: appName,
642 values: { [REDIS_URL_ENV]: value },
643 })
644 .pipe(
645 Effect.flatMap((response) => redisSecretVersion(appName, response)),
646 );
647 const version = yield* update.pipe(
648 Effect.catchTag("NotFound", () =>
649 machines
650 .createSecret({
651 app_name: appName,
652 secret_name: REDIS_URL_ENV,
653 value,
654 })
655 .pipe(
656 Effect.flatMap((response) => redisSecretVersion(appName, response)),
657 Effect.catchTag("Conflict", () => update),
658 ),
659 ),
660 );
661 versions.push(version);
662 }
663 return versions.length > 0 ? Math.max(...versions) : undefined;
664});
665
666export const RedisProvider = () =>
667 Provider.succeed(Redis, {
668 stables: ["redisId", "name", "primaryRegion", "orgSlug"],
669
670 diff: Effect.fn(function* ({ news, output }) {
671 if (news === undefined || !isResolved(news)) return undefined;
672 if (output === undefined) return undefined;
673 const desiredName =
674 news.name !== undefined ? sanitizeFlyAppName(news.name) : output.name;
675 const nameChanged = desiredName !== output.name;
676 const desiredRegion = news.primaryRegion ?? DEFAULT_REDIS_REGION;
677 const regionChanged = desiredRegion !== output.primaryRegion;
678 const orgChanged =
679 news.orgSlug !== undefined && news.orgSlug !== output.orgSlug;
680 if (nameChanged || regionChanged || orgChanged) {
681 return {
682 action: "replace" as const,
683 deleteFirst: !nameChanged,
684 };
685 }
686 return undefined;
687 }),
688
689 read: Effect.fn(function* ({ id, olds, output }) {
690 const name = yield* resolveName(id, olds?.name, output?.name);
691 const found =
692 (output?.redisId !== undefined
693 ? yield* findRedisAddOn({ id: output.redisId, name: output.name })
694 : undefined) ?? (yield* findRedisAddOn({ name }));
695 if (found === undefined) return undefined;
696 const attrs = toAttrs(found, {
697 name,
698 primaryRegion: olds?.primaryRegion ?? DEFAULT_REDIS_REGION,
699 orgSlug: olds?.orgSlug ?? output?.orgSlug,
700 url: output?.url,
701 });
702 if (output !== undefined) return attrs;
703 return isOwnedRedis(found) ? attrs : Unowned(attrs);
704 }),
705
706 list: Effect.fn(function* () {
707 const rows = yield* listRedisAddOns();
708 return rows.flatMap((row) => {
709 if (!isOwnedRedis(row)) return [];
710 const name = row.name;
711 if (name == null || name.length === 0) return [];
712 return [
713 toAttrs(row, {
714 name,
715 primaryRegion: row.primaryRegion ?? DEFAULT_REDIS_REGION,
716 }),
717 ];
718 });
719 }),
720
721 reconcile: Effect.fn(function* ({ id, news, output }) {
722 const props = news ?? {};
723 const name = yield* resolveName(id, props.name, output?.name);
724 const primaryRegion =
725 props.primaryRegion ?? output?.primaryRegion ?? DEFAULT_REDIS_REGION;
726 const orgSlug = props.orgSlug ?? (yield* resolveOrgSlug());
727 const org = yield* resolveOrganization(orgSlug);
728 if (org === undefined) {
729 return yield* new RedisOrgMissing({ orgSlug });
730 }
731
732 let current: ObservedRedis | undefined =
733 output?.redisId !== undefined
734 ? yield* findRedisAddOn({
735 id: output.redisId,
736 name: output.name,
737 })
738 : undefined;
739 if (
740 current === undefined &&
741 (output === undefined || output.name !== name)
742 ) {
743 current = yield* findRedisAddOn({ name });
744 }
745
746 const plan = yield* resolvePlan(
747 props.plan,
748 current !== undefined && "addOnPlan" in current
749 ? current.addOnPlan?.id
750 : output?.planId,
751 );
752
753 if (current === undefined) {
754 yield* ensureTos(orgSlug, org.id);
755 const created = yield* Effect.result(
756 addons.createAddOn({
757 input: {
758 type: REDIS_ADDON_TYPE,
759 name,
760 organizationId: org.id,
761 planId: plan.id,
762 primaryRegion,
763 readRegions: props.readRegions ?? [],
764 options: desiredOptions(props, plan, undefined),
765 },
766 }),
767 );
768 if (Result.isSuccess(created)) {
769 current = created.success.addOn;
770 } else {
771 current = yield* findRedisAddOn({ name });
772 if (current === undefined) {
773 return yield* Effect.fail(created.failure);
774 }
775 }
776 }
777
778 if (current === undefined) {
779 return yield* new RedisNotCreated({ name });
780 }
781
782 if (pendingStatus(current.status)) {
783 const ready = yield* waitUntilReady(current.id, name);
784 if (ready !== undefined) current = ready;
785 }
786
787 if (current === undefined) {
788 return yield* new RedisNotCreated({ name });
789 }
790
791 const observedOptions = recordOf(current.options);
792 const nextOptions = desiredOptions(props, plan, current.options);
793 const nextReadRegions = sorted(
794 props.readRegions ?? current.readRegions ?? [],
795 );
796 const observedReadRegions = sorted(current.readRegions);
797 const observedPlanId =
798 "addOnPlan" in current ? current.addOnPlan?.id : output?.planId;
799 const planChanged =
800 observedPlanId !== undefined && observedPlanId !== plan.id;
801 const regionsChanged = !deepEqual(observedReadRegions, nextReadRegions);
802 const optionsChanged = !deepEqual(observedOptions, nextOptions);
803 const prodPackChanged = props.prodPack !== undefined && planChanged;
804
805 if (planChanged || regionsChanged || optionsChanged || prodPackChanged) {
806 const updated = yield* Effect.result(
807 addons.updateAddOn({
808 input: {
809 addOnId: current.id,
810 planId: plan.id,
811 readRegions: nextReadRegions,
812 options: nextOptions,
813 prodPack: prodPackChanged ? props.prodPack : undefined,
814 },
815 }),
816 );
817 if (Result.isSuccess(updated)) {
818 current = {
819 ...current,
820 ...updated.success.addOn,
821 };
822 } else {
823 const refreshed = yield* findRedisAddOn({
824 id: current.id,
825 name,
826 });
827 if (refreshed !== undefined) current = refreshed;
828 }
829 }
830
831 let latest = (yield* findRedisAddOn({ id: current.id, name })) ?? current;
832 if (
833 unwrapSensitive(latest.publicUrl) === undefined &&
834 latest.id !== undefined
835 ) {
836 const detail = yield* addons.addOn({ id: latest.id });
837 latest = { ...latest, ...detail };
838 }
839 return toAttrs(latest, {
840 name,
841 primaryRegion,
842 orgSlug,
843 planId: plan.id,
844 url: output?.url,
845 });
846 }),
847
848 delete: Effect.fn(function* ({ output }) {
849 const redisId = output.redisId;
850 const name = output.name;
851 if (redisId.length === 0 && (name === undefined || name.length === 0)) {
852 return;
853 }
854 const deleted = yield* Effect.result(
855 addons.deleteAddOn({
856 input:
857 redisId.length > 0
858 ? { addOnId: redisId }
859 : { name, provider: REDIS_PROVIDER },
860 }),
861 );
862 if (Result.isFailure(deleted)) {
863 const still = yield* findRedisAddOn({ id: redisId, name });
864 if (still !== undefined) {
865 return yield* Effect.fail(deleted.failure);
866 }
867 return;
868 }
869 yield* waitUntilGone(redisId, name);
870 }),
871 });