1import * as alerting from "@distilled.cloud/cloudflare/alerting";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
5import * as Predicate from "effect/Predicate";
6import * as Schedule from "effect/Schedule";
7import * as Stream from "effect/Stream";
8
9import { Unowned } from "../../AdoptPolicy.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
13import type { Providers } from "../Providers.ts";
14
15const TypeId = "Cloudflare.Alerting.Silence" as const;
16type TypeId = typeof TypeId;
17
18export interface SilenceProps {
19 /**
20 * The notification policy to silence. References a
21 * {@link NotificationPolicy} id. Changing the policy triggers a
22 * replacement — the silence update API cannot move a silence to a
23 * different policy.
24 */
25 policyId: string;
26 /**
27 * When the silence window starts, as an RFC3339/ISO8601 timestamp (e.g.
28 * `2026-07-01T00:00:00Z`). Must be within 90 days of now — Cloudflare
29 * rejects windows further out with `InvalidSilence`. Pass an explicit,
30 * deterministic value; the provider never derives times from the clock.
31 * Mutable — updated in place.
32 */
33 startTime: string;
34 /**
35 * When the silence window ends, as an RFC3339/ISO8601 timestamp. Must be
36 * after `startTime` (`InvalidSilence` otherwise).
37 * Mutable — updated in place.
38 */
39 endTime: string;
40}
41
42export interface SilenceAttributes {
43 /** Cloudflare-assigned silence id. */
44 silenceId: string;
45 /** Account that owns this silence. */
46 accountId: string;
47 /** The notification policy this silence applies to. */
48 policyId: string;
49 /** When the silence window starts (ISO8601). */
50 startTime: string;
51 /** When the silence window ends (ISO8601). */
52 endTime: string;
53 /** ISO8601 creation timestamp. */
54 createdAt: string | undefined;
55 /** ISO8601 last-modified timestamp. */
56 updatedAt: string | undefined;
57}
58
59export type Silence = Resource<
60 TypeId,
61 SilenceProps,
62 SilenceAttributes,
63 never,
64 Providers
65>;
66
67/**
68 * A Cloudflare Notifications silence window.
69 *
70 * A silence suppresses notification dispatches for a
71 * {@link NotificationPolicy} between `startTime` and `endTime` — e.g.
72 * during a planned maintenance window. The window times are explicit
73 * ISO8601 props supplied by you; Cloudflare requires the start time to be
74 * within 90 days of now.
75 *
76 * Note: the create API returns no id, so the provider resolves the created
77 * silence by listing and matching on `(policyId, startTime, endTime)`. Two
78 * silences sharing the exact same policy and window are indistinguishable.
79 * ### Creating a silence
80 * **Example:** Silence a policy during a maintenance window
81 * ```typescript
82 * const policy = yield* Cloudflare.Alerting.NotificationPolicy("SslAlerts", {
83 * alertType: "universal_ssl_event_type",
84 * mechanisms: { email: [{ id: "ops@example.com" }] },
85 * });
86 *
87 * yield* Cloudflare.Alerting.Silence("MaintenanceWindow", {
88 * policyId: policy.policyId,
89 * startTime: "2026-07-01T00:00:00Z",
90 * endTime: "2026-07-01T04:00:00Z",
91 * });
92 * ```
93 *
94 * ### Updating the window
95 * **Example:** Extend the silence end time in place
96 * Window times are mutable — changing them updates the existing silence.
97 * ```typescript
98 * yield* Cloudflare.Alerting.Silence("MaintenanceWindow", {
99 * policyId: policy.policyId,
100 * startTime: "2026-07-01T00:00:00Z",
101 * endTime: "2026-07-01T08:00:00Z",
102 * });
103 * ```
104 *
105 * @see https://developers.cloudflare.com/notifications/
106 *
107 * @resource
108 * @product Alerting
109 * @category Observability & Analytics
110 */
111export const Silence = Resource<Silence>(TypeId);
112
113/**
114 * Returns true if the given value is a Silence resource.
115 */
116export const isSilence = (value: unknown): value is Silence =>
117 Predicate.hasProperty(value, "Type") && value.Type === TypeId;
118
119export const SilenceProvider = () =>
120 Provider.succeed(Silence, {
121 stables: ["silenceId", "accountId", "policyId", "createdAt"],
122
123 // Silences are account-scoped; listSilences enumerates every silence in
124 // the account and the response carries the full attribute shape, so no
125 // per-item hydration is needed.
126 list: Effect.fn(function* () {
127 const { accountId } = yield* yield* CloudflareEnvironment;
128 return yield* alerting.listSilences.pages({ accountId }).pipe(
129 Stream.runCollect,
130 Effect.map((chunk) =>
131 Array.from(chunk).flatMap((page) =>
132 (page.result ?? [])
133 .map(narrowSilence)
134 .filter((s): s is ObservedSilence => s !== undefined)
135 .map((s) => toSilenceAttributes(s, accountId)),
136 ),
137 ),
138 );
139 }),
140
141 diff: Effect.fn(function* ({ olds = {}, news, output }) {
142 const { accountId } = yield* yield* CloudflareEnvironment;
143 if ((output?.accountId ?? accountId) !== accountId) {
144 return { action: "replace" } as const;
145 }
146 // The silence update body carries no policyId — a silence cannot be
147 // moved to a different policy, so a policy change is a replacement.
148 // policyId is Input<string>; compare only once both are concrete.
149 const o = olds as SilenceProps;
150 const n = news as SilenceProps;
151 const oldPolicyId =
152 output?.policyId ??
153 (typeof o.policyId === "string" ? o.policyId : undefined);
154 if (
155 oldPolicyId !== undefined &&
156 typeof n.policyId === "string" &&
157 oldPolicyId !== n.policyId
158 ) {
159 return { action: "replace" } as const;
160 }
161 }),
162
163 read: Effect.fn(function* ({ output, olds }) {
164 const { accountId } = yield* yield* CloudflareEnvironment;
165 const acct = output?.accountId ?? accountId;
166
167 // Owned path: refresh by the persisted silence id.
168 if (output?.silenceId) {
169 const observed = yield* observeSilence(acct, output.silenceId);
170 if (observed) return toSilenceAttributes(observed, acct);
171 return undefined;
172 }
173
174 // Cold read: no persisted id (state-persistence failure) — match on
175 // the silence identity `(policyId, startTime, endTime)`. Silences
176 // carry no ownership markers, so we cannot prove we created a match:
177 // brand it `Unowned` so the engine gates takeover behind adoption.
178 const o = olds as SilenceProps | undefined;
179 const policyId = typeof o?.policyId === "string" ? o.policyId : undefined;
180 if (policyId && o?.startTime && o?.endTime) {
181 const match = yield* findSilence(
182 acct,
183 policyId,
184 o.startTime,
185 o.endTime,
186 );
187 if (match) return Unowned(toSilenceAttributes(match, acct));
188 }
189 return undefined;
190 }),
191
192 reconcile: Effect.fn(function* ({ news, output }) {
193 const { accountId } = yield* yield* CloudflareEnvironment;
194 // Inputs are resolved to concrete values by the engine before
195 // reconcile runs.
196 const policyId = news.policyId as string;
197
198 // 1. Observe — by cached id first, then by silence identity.
199 let observed: ObservedSilence | undefined;
200 if (output?.silenceId) {
201 observed = yield* observeSilence(accountId, output.silenceId);
202 }
203 if (!observed) {
204 observed = yield* findSilence(
205 accountId,
206 policyId,
207 news.startTime,
208 news.endTime,
209 );
210 }
211
212 // 2. Ensure — create when missing. The create response carries no
213 // id, so resolve the created silence by listing and matching on
214 // `(policyId, startTime, endTime)` with a short bounded retry for
215 // read-after-write consistency. A concurrent create of the same
216 // window surfaces as `SilenceAlreadyExists`: converge by reusing
217 // the silence that won the race.
218 if (!observed) {
219 yield* alerting
220 .createSilence({
221 accountId,
222 body: [
223 {
224 policyId,
225 startTime: news.startTime,
226 endTime: news.endTime,
227 },
228 ],
229 })
230 .pipe(
231 Effect.catchTag("SilenceAlreadyExists", (error) =>
232 findSilence(
233 accountId,
234 policyId,
235 news.startTime,
236 news.endTime,
237 ).pipe(
238 Effect.flatMap((existing) =>
239 existing ? Effect.void : Effect.fail(error),
240 ),
241 ),
242 ),
243 );
244 observed = yield* findSilence(
245 accountId,
246 policyId,
247 news.startTime,
248 news.endTime,
249 ).pipe(
250 Effect.flatMap((found) =>
251 found ? Effect.succeed(found) : Effect.fail(new SilencePending()),
252 ),
253 Effect.retry({
254 while: (e) => e._tag === "SilencePending",
255 schedule: Schedule.max([
256 Schedule.exponential("500 millis"),
257 Schedule.recurs(8),
258 ]),
259 }),
260 );
261 return toSilenceAttributes(observed, accountId);
262 }
263
264 // 3. Sync — diff the observed window against the desired one
265 // (instant-equal, so `00:00:00Z` matches `00:00:00+00:00`) and
266 // PUT only on drift.
267 if (
268 !sameInstant(observed.startTime, news.startTime) ||
269 !sameInstant(observed.endTime, news.endTime)
270 ) {
271 yield* alerting.updateSilence({
272 accountId,
273 body: [
274 {
275 id: observed.id,
276 startTime: news.startTime,
277 endTime: news.endTime,
278 },
279 ],
280 });
281 const fresh = yield* observeSilence(accountId, observed.id);
282 return toSilenceAttributes(fresh ?? observed, accountId);
283 }
284
285 // 4. Return.
286 return toSilenceAttributes(observed, accountId);
287 }),
288
289 delete: Effect.fn(function* ({ output }) {
290 yield* alerting
291 .deleteSilence({
292 accountId: output.accountId,
293 silenceId: output.silenceId,
294 })
295 .pipe(Effect.catchTag("SilenceNotFound", () => Effect.void));
296 }),
297 });
298
299/** Created silence not yet visible in the list — retried internally. */
300class SilencePending extends Data.TaggedError("SilencePending") {}
301
302interface ObservedSilence {
303 readonly id: string;
304 readonly policyId?: string;
305 readonly startTime?: string;
306 readonly endTime?: string;
307 readonly createdAt?: string;
308 readonly updatedAt?: string;
309}
310
311const undef = <T>(v: T | null | undefined): T | undefined =>
312 v == null ? undefined : v;
313
314const narrowSilence = (raw: {
315 id?: string | null;
316 policyId?: string | null;
317 startTime?: string | null;
318 endTime?: string | null;
319 createdAt?: string | null;
320 updatedAt?: string | null;
321}): ObservedSilence | undefined =>
322 raw.id == null
323 ? undefined
324 : {
325 id: raw.id,
326 policyId: undef(raw.policyId),
327 startTime: undef(raw.startTime),
328 endTime: undef(raw.endTime),
329 createdAt: undef(raw.createdAt),
330 updatedAt: undef(raw.updatedAt),
331 };
332
333const observeSilence = (accountId: string, silenceId: string) =>
334 alerting.getSilence({ accountId, silenceId }).pipe(
335 Effect.map((s) => narrowSilence({ ...s, id: s.id ?? silenceId })),
336 Effect.catchTag("SilenceNotFound", () => Effect.succeed(undefined)),
337 );
338
339/**
340 * Locate a silence by its identity `(policyId, startTime, endTime)`.
341 * Timestamps are compared as instants so formatting differences (trailing
342 * `Z` vs `+00:00`, fractional seconds) don't defeat the match.
343 */
344const findSilence = (
345 accountId: string,
346 policyId: string,
347 startTime: string,
348 endTime: string,
349) =>
350 alerting.listSilences.items({ accountId }).pipe(
351 Stream.filter(
352 (s) =>
353 s.id != null &&
354 s.policyId === policyId &&
355 sameInstant(undef(s.startTime), startTime) &&
356 sameInstant(undef(s.endTime), endTime),
357 ),
358 Stream.runHead,
359 Effect.map(Option.getOrUndefined),
360 Effect.map((s) => (s === undefined ? undefined : narrowSilence(s))),
361 );
362
363/** Compare two ISO8601 timestamps by the instant they denote. */
364const sameInstant = (a: string | undefined, b: string | undefined): boolean => {
365 if (a === undefined || b === undefined) return a === b;
366 const ta = Date.parse(a);
367 const tb = Date.parse(b);
368 if (Number.isNaN(ta) || Number.isNaN(tb)) return a === b;
369 return ta === tb;
370};
371
372const toSilenceAttributes = (
373 observed: ObservedSilence,
374 accountId: string,
375): SilenceAttributes => ({
376 silenceId: observed.id,
377 accountId,
378 policyId: observed.policyId ?? "",
379 startTime: observed.startTime ?? "",
380 endTime: observed.endTime ?? "",
381 createdAt: observed.createdAt,
382 updatedAt: observed.updatedAt,
383});