1import * as forecast from "@distilled.cloud/aws/forecast";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import { diffTags } from "../../Tags.ts";
5
6/**
7 * Unwrap a Forecast `SensitiveString` (decoded as `Redacted`) to its plain
8 * string value. `String(redacted)` would yield `"<redacted>"`, not the value.
9 */
10export const unredact = (value: string | Redacted.Redacted<string>): string =>
11 Redacted.isRedacted(value) ? Redacted.value(value) : value;
12
13/**
14 * Coerce a Forecast wire tag list (`{ Key, Value }[]`, values decode as
15 * `Redacted`) into a plain `Record<string, string>`.
16 */
17export const toTagRecord = (
18 tags: forecast.Tag[] | undefined,
19): Record<string, string> =>
20 Object.fromEntries(
21 (tags ?? []).map((t) => [unredact(t.Key), unredact(t.Value)] as const),
22 );
23
24/**
25 * Read the observed tags of a Forecast resource by ARN. Tag reads are
26 * best-effort — a failure (e.g. a race with deletion) reports no tags.
27 */
28export const readForecastTags = Effect.fn(function* (arn: string) {
29 const response = yield* forecast
30 .listTagsForResource({ ResourceArn: arn })
31 .pipe(Effect.catch(() => Effect.succeed(undefined)));
32 return toTagRecord(response?.Tags);
33});
34
35/**
36 * Sync tags on a Forecast resource: diff the OBSERVED cloud tags against the
37 * desired set and apply only the delta.
38 */
39export const syncForecastTags = Effect.fn(function* (
40 arn: string,
41 desiredTags: Record<string, string>,
42) {
43 const observedTags = yield* readForecastTags(arn);
44 const { removed, upsert } = diffTags(observedTags, desiredTags);
45 if (upsert.length > 0) {
46 yield* forecast.tagResource({
47 ResourceArn: arn,
48 Tags: upsert.map((t) => ({ Key: t.Key, Value: t.Value })),
49 });
50 }
51 if (removed.length > 0) {
52 // TagKeys decodes as SensitiveString — wrap in Redacted for the encoder
53 // (which unwraps back to the plain string on the wire).
54 yield* forecast.untagResource({
55 ResourceArn: arn,
56 TagKeys: removed.map((key) => Redacted.make(key)),
57 });
58 }
59});
60
61/**
62 * Coerce a generated physical name into Forecast's identifier constraint
63 * (`^[a-zA-Z][a-zA-Z0-9_]*` — underscores only, must start with a letter).
64 * `createPhysicalName` emits DNS-style hyphens, which Forecast rejects.
65 */
66export const toForecastName = (name: string) => {
67 const sanitized = name.replaceAll(/[^a-zA-Z0-9_]/g, "_");
68 return /^[a-zA-Z]/.test(sanitized) ? sanitized : `f${sanitized.slice(0, 62)}`;
69};