1import * as frauddetector from "@distilled.cloud/aws/frauddetector";
2import * as Effect from "effect/Effect";
3import { deepEqual, isResolved } from "../../Diff.ts";
4import * as Provider from "../../Provider.ts";
5import { Resource } from "../../Resource.ts";
6import { createInternalTags } from "../../Tags.ts";
7import type { Providers } from "../Providers.ts";
8import { syncFraudDetectorTags } from "./internal.ts";
9
10/**
11 * An inline rule definition owned by a detector version. Fraud Detector rules
12 * are versioned resources scoped to a detector; a detector version references
13 * a specific rule version. Declaring rules inline here lets the detector
14 * version own their whole lifecycle.
15 */
16export interface RuleDefinition {
17 /**
18 * Identifier of the rule, unique within the detector (e.g. `high_risk`).
19 */
20 ruleId: string;
21 /**
22 * The rule expression, written in the rule language (see `language`). It
23 * references variables and evaluates to a boolean, e.g.
24 * `$order_price > 1000 and $email == "unknown"`.
25 */
26 expression: string;
27 /**
28 * Names of the outcomes returned when the rule matches. Must reference
29 * existing Fraud Detector outcomes.
30 */
31 outcomes: string[];
32 /**
33 * The rule language. Only `DETECTORPL` is supported.
34 * @default "DETECTORPL"
35 */
36 language?: string;
37 /**
38 * Human-readable description of the rule.
39 */
40 description?: string;
41}
42
43export interface DetectorVersionProps {
44 /**
45 * The identifier of the detector this version belongs to. Immutable —
46 * changing it replaces the detector version.
47 */
48 detectorId: string;
49 /**
50 * Human-readable description of the version. Changing it replaces the
51 * version (published versions are immutable).
52 */
53 description?: string;
54 /**
55 * The rules evaluated by this version, in priority order. Changing the set
56 * of rules replaces the version.
57 */
58 rules: RuleDefinition[];
59 /**
60 * How rules are evaluated: `FIRST_MATCHED` stops at the first matching rule,
61 * `ALL_MATCHED` evaluates every rule. Changing it replaces the version.
62 * @default "FIRST_MATCHED"
63 */
64 ruleExecutionMode?: string;
65 /**
66 * The desired status of the version: `DRAFT`, `ACTIVE`, or `INACTIVE`. Only
67 * an `ACTIVE` version serves predictions. This is an in-place update.
68 * @default "ACTIVE"
69 */
70 status?: string;
71 /**
72 * User-defined tags for the detector version.
73 */
74 tags?: Record<string, string>;
75}
76
77export interface DetectorVersion extends Resource<
78 "AWS.FraudDetector.DetectorVersion",
79 DetectorVersionProps,
80 {
81 /** The identifier of the parent detector. */
82 detectorId: string;
83 /** The generated version identifier, e.g. `"1"`. */
84 detectorVersionId: string;
85 /** The ARN of the detector version. */
86 arn: string;
87 /** The version status: `DRAFT`, `ACTIVE`, or `INACTIVE`. */
88 status: string;
89 },
90 never,
91 Providers
92> {}
93
94/**
95 * An Amazon Fraud Detector detector version — the deployable revision of a
96 * detector. It bundles a set of rules (owned inline here) over the detector's
97 * event type and, when `ACTIVE`, serves real-time predictions via
98 * `getEventPrediction`. Rules and the version are cheap, rule-based
99 * configuration objects — no model training is involved.
100 *
101 * ### Creating a Detector Version
102 * **Example:** Active Version with One Rule
103 * ```typescript
104 * const version = yield* FraudDetector.DetectorVersion("v1", {
105 * detectorId: detector.detectorId,
106 * status: "ACTIVE",
107 * ruleExecutionMode: "FIRST_MATCHED",
108 * rules: [
109 * {
110 * ruleId: "high_risk",
111 * expression: '$email == "fraud@example.com"',
112 * outcomes: ["review"],
113 * },
114 * ],
115 * });
116 * ```
117 *
118 * @resource
119 */
120export const DetectorVersion = Resource<DetectorVersion>(
121 "AWS.FraudDetector.DetectorVersion",
122);
123
124export const DetectorVersionProvider = () =>
125 Provider.effect(
126 DetectorVersion,
127 Effect.gen(function* () {
128 /** Read a detector version by id; typed not-found → undefined. */
129 const getVersion = Effect.fn(function* (
130 detectorId: string,
131 detectorVersionId: string,
132 ) {
133 return yield* frauddetector
134 .getDetectorVersion({ detectorId, detectorVersionId })
135 .pipe(
136 Effect.catchTag("ResourceNotFoundException", () =>
137 Effect.succeed(undefined),
138 ),
139 );
140 });
141
142 /**
143 * Ensure every declared rule exists as a rule version and return the
144 * references (create-once by `ruleId`; existing rules reuse their latest
145 * version — rule-expression edits are expressed by changing `ruleId`).
146 */
147 const ensureRules = Effect.fn(function* (
148 detectorId: string,
149 rules: RuleDefinition[],
150 ) {
151 const refs: frauddetector.Rule[] = [];
152 for (const rule of rules) {
153 const existing = yield* frauddetector
154 .getRules({ detectorId, ruleId: rule.ruleId })
155 .pipe(
156 Effect.catchTag("ResourceNotFoundException", () =>
157 Effect.succeed(undefined),
158 ),
159 );
160 const details = existing?.ruleDetails ?? [];
161 if (details.length === 0) {
162 const created = yield* frauddetector.createRule({
163 ruleId: rule.ruleId,
164 detectorId,
165 expression: rule.expression,
166 language: rule.language ?? "DETECTORPL",
167 outcomes: rule.outcomes,
168 description: rule.description,
169 });
170 refs.push(created.rule!);
171 } else {
172 // Reuse the latest existing version of the rule.
173 const latest = details.reduce((a, b) =>
174 Number(b.ruleVersion ?? "0") > Number(a.ruleVersion ?? "0")
175 ? b
176 : a,
177 );
178 refs.push({
179 detectorId,
180 ruleId: latest.ruleId!,
181 ruleVersion: latest.ruleVersion!,
182 });
183 }
184 }
185 return refs;
186 });
187
188 return {
189 stables: ["detectorId", "detectorVersionId", "arn"],
190
191 diff: Effect.fn(function* ({ olds = {}, news }) {
192 if (!isResolved(news)) return undefined;
193 // Everything but status is baked into the published version — a
194 // change to the detector, rules, execution mode, or description
195 // replaces the version. Status transitions happen in place.
196 if (
197 (olds.detectorId ?? undefined) !== (news.detectorId ?? undefined) ||
198 (olds.ruleExecutionMode ?? undefined) !==
199 (news.ruleExecutionMode ?? undefined) ||
200 (olds.description ?? undefined) !==
201 (news.description ?? undefined) ||
202 !deepEqual(olds.rules, news.rules)
203 ) {
204 return { action: "replace" } as const;
205 }
206 }),
207
208 read: Effect.fn(function* ({ output }) {
209 if (output?.detectorVersionId === undefined) return undefined;
210 const version = yield* getVersion(
211 output.detectorId,
212 output.detectorVersionId,
213 );
214 if (version === undefined) return undefined;
215 return {
216 detectorId: version.detectorId!,
217 detectorVersionId: version.detectorVersionId!,
218 arn: version.arn!,
219 status: version.status!,
220 };
221 }),
222
223 reconcile: Effect.fn(function* ({ id, news, output, session }) {
224 const detectorId = news.detectorId;
225 const desiredStatus = news.status ?? "ACTIVE";
226 const internalTags = yield* createInternalTags(id);
227 const desiredTags = { ...internalTags, ...news.tags };
228
229 // 1. ENSURE the rules exist and collect their version references.
230 const ruleRefs = yield* ensureRules(detectorId, news.rules);
231
232 // 2. OBSERVE the version; create it if missing. Published versions
233 // are immutable, so any content change triggers replacement (see
234 // diff) — here we only ever create or converge status/tags.
235 let detectorVersionId = output?.detectorVersionId;
236 let version =
237 detectorVersionId !== undefined
238 ? yield* getVersion(detectorId, detectorVersionId)
239 : undefined;
240
241 if (version === undefined) {
242 const created = yield* frauddetector.createDetectorVersion({
243 detectorId,
244 description: news.description,
245 rules: ruleRefs,
246 ruleExecutionMode: news.ruleExecutionMode,
247 tags: Object.entries(desiredTags).map(([key, value]) => ({
248 key,
249 value,
250 })),
251 });
252 detectorVersionId = created.detectorVersionId!;
253 version = yield* getVersion(detectorId, detectorVersionId);
254 }
255
256 // 3. SYNC status — a new version is created in DRAFT; move it to the
257 // desired status if it drifts.
258 if ((version!.status ?? undefined) !== desiredStatus) {
259 yield* frauddetector.updateDetectorVersionStatus({
260 detectorId,
261 detectorVersionId: detectorVersionId!,
262 status: desiredStatus,
263 });
264 }
265
266 // 4. SYNC tags — diff against OBSERVED cloud tags.
267 yield* syncFraudDetectorTags(version!.arn!, desiredTags);
268
269 yield* session.note(detectorVersionId!);
270 return {
271 detectorId,
272 detectorVersionId: detectorVersionId!,
273 arn: version!.arn!,
274 status: desiredStatus,
275 };
276 }),
277
278 delete: Effect.fn(function* ({ output }) {
279 const { detectorId, detectorVersionId } = output;
280 // Read the rules the version owns before removing it so we can clean
281 // them up afterwards (rules block detector deletion otherwise).
282 const version = yield* getVersion(detectorId, detectorVersionId);
283 const rules = version?.rules ?? [];
284
285 // A version must be INACTIVE before it can be deleted.
286 yield* frauddetector
287 .updateDetectorVersionStatus({
288 detectorId,
289 detectorVersionId,
290 status: "INACTIVE",
291 })
292 .pipe(
293 Effect.catchTag(
294 [
295 "ValidationException",
296 "ConflictException",
297 "ResourceNotFoundException",
298 ],
299 () => Effect.void,
300 ),
301 );
302
303 yield* frauddetector
304 .deleteDetectorVersion({ detectorId, detectorVersionId })
305 .pipe(
306 Effect.catchTag(
307 [
308 "ValidationException",
309 "ConflictException",
310 "ResourceNotFoundException",
311 ],
312 () => Effect.void,
313 ),
314 );
315
316 // Best-effort cleanup of the owned rule versions.
317 for (const rule of rules) {
318 yield* frauddetector
319 .deleteRule({ rule })
320 .pipe(
321 Effect.catchTag(
322 ["ValidationException", "ConflictException"],
323 () => Effect.void,
324 ),
325 );
326 }
327 }),
328
329 // Detector versions are sub-resources keyed by their parent detector;
330 // there is no account-wide enumeration op, so list is a no-op.
331 list: () => Effect.succeed([]),
332 };
333 }),
334 );