1import * as Duration from "effect/Duration";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Result from "effect/Result";
5import {
6 getService,
7 createServicePromote,
8 createServiceRollback,
9} from "@distilled.cloud/prisma/management";
10import type { ObservedApp } from "./Observed.ts";
11import type { PromoteAppResult } from "../Types.ts";
12
13export interface AppDeploymentTargetObservationOptions {
14 readonly timeoutSeconds?: number;
15 readonly pollIntervalMs?: number;
16}
17
18const DEFAULT_TIMEOUT_SECONDS = 60;
19const DEFAULT_POLL_INTERVAL_MS = 1_000;
20
21export const waitForAppDeploymentTarget = Effect.fn(function* (
22 appId: string,
23 deploymentId: string,
24 options: AppDeploymentTargetObservationOptions = {},
25) {
26 const timeoutSeconds = options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS;
27 const intervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
28 if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
29 return yield* Effect.fail(
30 new Error("timeoutSeconds must be a positive finite number."),
31 );
32 }
33 if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
34 return yield* Effect.fail(
35 new Error("pollIntervalMs must be a positive finite number."),
36 );
37 }
38
39 const timeoutMs = timeoutSeconds * 1_000;
40 const deadline = yield* Effect.sync(() => Date.now() + timeoutMs);
41 let lastLatestDeploymentId: string | null | undefined;
42 let lastError: unknown;
43
44 while (true) {
45 const remainingBeforeObservation = yield* Effect.sync(
46 () => deadline - Date.now(),
47 );
48 if (remainingBeforeObservation <= 0) break;
49
50 const observationOption = yield* getService({ serviceId: appId }).pipe(
51 Effect.map((response) => response.data),
52 Effect.result,
53 Effect.timeoutOption(Duration.millis(remainingBeforeObservation)),
54 );
55 if (Option.isNone(observationOption)) break;
56 const observation = observationOption.value;
57 if (Result.isSuccess(observation)) {
58 lastLatestDeploymentId = observation.success.latestDeploymentId;
59 lastError = undefined;
60 if (lastLatestDeploymentId === deploymentId) {
61 return observation.success;
62 }
63 } else {
64 lastError = observation.failure;
65 }
66
67 const remainingAfterObservation = yield* Effect.sync(
68 () => deadline - Date.now(),
69 );
70 if (remainingAfterObservation <= 0) break;
71 yield* Effect.sleep(
72 Duration.millis(Math.min(intervalMs, remainingAfterObservation)),
73 );
74 }
75
76 return yield* Effect.fail(
77 new AggregateError(
78 lastError === undefined ? [] : [lastError],
79 `Timed out waiting for Prisma App '${appId}' to report deployment '${deploymentId}' as latest (last observed latest deployment: '${lastLatestDeploymentId ?? "none"}').`,
80 ),
81 );
82});
83
84/**
85 * Promote an App and recover response loss by observing the canonical App.
86 * Every successful mutation is followed by bounded observation of the App
87 * row before callers may probe stable readiness or delete another deployment.
88 * If the promotion response is lost, the canonical rollback primitive heals
89 * an already-flipped Foundry endpoint or safely completes the target
90 * promotion. Any non-converged result is ambiguous and must never trigger
91 * target deletion.
92 */
93export const promoteAppObserved = Effect.fn(function* (
94 appId: string,
95 deploymentId: string,
96 options: AppDeploymentTargetObservationOptions = {},
97) {
98 const promoted = yield* createServicePromote({
99 serviceId: appId,
100 deploymentId,
101 }).pipe(
102 Effect.map((response) => response.data),
103 Effect.result,
104 );
105 if (Result.isSuccess(promoted)) {
106 const observation = yield* waitForAppDeploymentTarget(
107 appId,
108 deploymentId,
109 options,
110 ).pipe(Effect.result);
111 if (Result.isFailure(observation)) {
112 return yield* Effect.fail(
113 new AggregateError(
114 [observation.failure],
115 `Prisma App '${appId}' promotion returned success for deployment '${deploymentId}', but the App did not converge to that target. The deployment was preserved because promotion commit state is ambiguous.`,
116 ),
117 );
118 }
119 return {
120 ...promoted.success,
121 appEndpointDomain: observation.success.appEndpointDomain,
122 };
123 }
124
125 const observation = yield* getService({ serviceId: appId }).pipe(
126 Effect.map((response) => response.data),
127 Effect.result,
128 );
129 if (
130 Result.isSuccess(observation) &&
131 observation.success.latestDeploymentId === deploymentId
132 ) {
133 return {
134 appEndpointDomain: observation.success.appEndpointDomain,
135 reassignedDomains: 0,
136 };
137 }
138 const repaired = yield* createServiceRollback({
139 serviceId: appId,
140 deploymentId,
141 }).pipe(
142 Effect.map((response) => response.data),
143 Effect.result,
144 );
145 const repairedObservation = Result.isSuccess(repaired)
146 ? yield* waitForAppDeploymentTarget(appId, deploymentId, options).pipe(
147 Effect.result,
148 )
149 : yield* getService({ serviceId: appId }).pipe(
150 Effect.map((response) => response.data),
151 Effect.result,
152 );
153 if (
154 Result.isSuccess(repairedObservation) &&
155 repairedObservation.success.latestDeploymentId === deploymentId
156 ) {
157 return Result.isSuccess(repaired)
158 ? {
159 ...repaired.success,
160 appEndpointDomain: repairedObservation.success.appEndpointDomain,
161 }
162 : {
163 appEndpointDomain: repairedObservation.success.appEndpointDomain,
164 reassignedDomains: 0,
165 };
166 }
167 return yield* Effect.fail(
168 new AggregateError(
169 [
170 promoted.failure,
171 ...(Result.isFailure(observation) ? [observation.failure] : []),
172 ...(Result.isFailure(repaired) ? [repaired.failure] : []),
173 ...(Result.isFailure(repairedObservation)
174 ? [repairedObservation.failure]
175 : [
176 new Error(
177 `Prisma App '${appId}' still reports deployment '${repairedObservation.success.latestDeploymentId ?? "none"}' as latest after canonical recovery targeted '${deploymentId}'.`,
178 ),
179 ]),
180 ],
181 `Prisma App '${appId}' promotion of deployment '${deploymentId}' failed and canonical recovery also failed. The deployment was not deleted because endpoint commit state is ambiguous.`,
182 ),
183 );
184});