| 1 | import * as alerting from "@distilled.cloud/cloudflare/alerting"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Option from "effect/Option"; |
| 5 | import * as Predicate from "effect/Predicate"; |
| 6 | import * as Schedule from "effect/Schedule"; |
| 7 | import * as Stream from "effect/Stream"; |
| 8 | |
| 9 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 10 | import * as Provider from "../../Provider.ts"; |
| 11 | import { Resource } from "../../Resource.ts"; |
| 12 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 13 | import type { Providers } from "../Providers.ts"; |
| 14 | |
| 15 | const TypeId = "Cloudflare.Alerting.Silence" as const; |
| 16 | type TypeId = typeof TypeId; |
| 17 | |
| 18 | export 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 | |
| 42 | export 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 | |
| 59 | export 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 | */ |
| 111 | export const Silence = Resource<Silence>(TypeId); |
| 112 | |
| 113 | /** |
| 114 | * Returns true if the given value is a Silence resource. |
| 115 | */ |
| 116 | export const isSilence = (value: unknown): value is Silence => |
| 117 | Predicate.hasProperty(value, "Type") && value.Type === TypeId; |
| 118 | |
| 119 | export 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. */ |
| 300 | class SilencePending extends Data.TaggedError("SilencePending") {} |
| 301 | |
| 302 | interface 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 | |
| 311 | const undef = <T>(v: T | null | undefined): T | undefined => |
| 312 | v == null ? undefined : v; |
| 313 | |
| 314 | const 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 | |
| 333 | const 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 | */ |
| 344 | const 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. */ |
| 364 | const 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 | |
| 372 | const 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 | }); |