1import * as Effect from "effect/Effect";
2import { createPhysicalName } from "../../PhysicalName.ts";
3import {
4 alchemyLabelKeys,
5 createInternalLabels,
6 hasAlchemyLabels,
7 sanitizeLabelValue,
8} from "../Labels.ts";
9
10export const DEFAULT_DATA_POLICY_TYPE = "RAW_DATA_ACCESS_POLICY";
11export const MAX_NAME_LENGTH = 200;
12/**
13 * Locations swept by `list`, alongside the stack region. `us-central1` stays
14 * so resources created under the former hard-coded default remain reachable.
15 */
16export const LIST_LOCATIONS = ["-", "us-central1", "US", "EU"] as const;
17const OWNER_PREFIX = "alch___";
18const OWNER_SEP = "___";
19
20export const lastSegment = (value: string) => {
21 const trimmed = value.replace(/\/+$/, "");
22 const parts = trimmed.split("/");
23 return parts[parts.length - 1] || trimmed;
24};
25
26export const normalizeLocation = (
27 location: string | undefined,
28 defaultLocation: string,
29) => {
30 const value = lastSegment(location ?? defaultLocation);
31 const upper = value.toUpperCase();
32 if (upper === "US" || upper === "EU") return upper;
33 return value.toLowerCase();
34};
35
36export const parentOf = (project: string, location: string) =>
37 `projects/${project}/locations/${location}`;
38
39export const resourceNameOf = (
40 project: string,
41 location: string,
42 dataPolicyId: string,
43) => `${parentOf(project, location)}/dataPolicies/${dataPolicyId}`;
44
45export const parseName = (
46 name: string,
47 fallbackProject: string,
48 fallbackLocation: string,
49) => {
50 const parts = name.split("/").filter((part) => part.length > 0);
51 const collectionAt = parts.lastIndexOf("dataPolicies");
52 const locationsAt = parts.lastIndexOf("locations");
53 const projectsAt = parts.lastIndexOf("projects");
54 return {
55 project:
56 projectsAt >= 0 && parts[projectsAt + 1]
57 ? parts[projectsAt + 1]!
58 : fallbackProject,
59 location:
60 locationsAt >= 0 && parts[locationsAt + 1]
61 ? parts[locationsAt + 1]!
62 : fallbackLocation,
63 dataPolicyId:
64 collectionAt >= 0 && parts[collectionAt + 1]
65 ? parts[collectionAt + 1]!
66 : lastSegment(name),
67 };
68};
69
70export const compact = <T extends Record<string, unknown>>(value: T): T =>
71 Object.fromEntries(
72 Object.entries(value).filter(([, item]) => item !== undefined),
73 ) as T;
74
75const sanitizeIdPart = (value: string) => {
76 const cleaned = sanitizeLabelValue(value)
77 .replace(/-/g, "_")
78 .replace(/_+/g, "_")
79 .replace(/^_|_$/g, "");
80 return cleaned.length > 0 ? cleaned : "x";
81};
82
83const ensureId = (value: string) => {
84 const cleaned = value
85 .toLowerCase()
86 .replace(/[^a-z0-9_]/g, "_")
87 .replace(/^_|_$/g, "")
88 .slice(0, MAX_NAME_LENGTH)
89 .replace(/_+$/g, "");
90 if (cleaned.length === 0) return "alchx";
91 return /^[a-z]/.test(cleaned)
92 ? cleaned
93 : `a${cleaned}`.slice(0, MAX_NAME_LENGTH);
94};
95
96export const encodeOwnershipId = (
97 labels: Record<string, string>,
98 extra?: string,
99): string => {
100 let stack = sanitizeIdPart(labels[alchemyLabelKeys.stack] ?? "x");
101 let stage = sanitizeIdPart(labels[alchemyLabelKeys.stage] ?? "x");
102 let id = sanitizeIdPart(labels[alchemyLabelKeys.id] ?? "x");
103 let rest = extra ? sanitizeIdPart(extra) : "";
104 const build = () =>
105 rest.length > 0
106 ? `${OWNER_PREFIX}${stack}${OWNER_SEP}${stage}${OWNER_SEP}${id}${OWNER_SEP}${rest}`
107 : `${OWNER_PREFIX}${stack}${OWNER_SEP}${stage}${OWNER_SEP}${id}`;
108 let name = build();
109 while (name.length > MAX_NAME_LENGTH) {
110 if (rest.length > 1) rest = rest.slice(0, -1);
111 else if (id.length > 1) id = id.slice(0, -1);
112 else if (stack.length > 1) stack = stack.slice(0, -1);
113 else if (stage.length > 1) stage = stage.slice(0, -1);
114 else break;
115 name = build();
116 }
117 return name.slice(0, MAX_NAME_LENGTH).replace(/_+$/g, "");
118};
119
120export const parseOwnershipId = (
121 resourceId: string | undefined,
122): { labels: Record<string, string> } => {
123 const value = resourceId ?? "";
124 if (!value.startsWith(OWNER_PREFIX)) return { labels: {} };
125 const parts = value.slice(OWNER_PREFIX.length).split(OWNER_SEP);
126 if (parts.length < 3) return { labels: {} };
127 return {
128 labels: {
129 [alchemyLabelKeys.stack]: parts[0] ?? "",
130 [alchemyLabelKeys.stage]: parts[1] ?? "",
131 [alchemyLabelKeys.id]: parts[2] ?? "",
132 },
133 };
134};
135
136export const hasOwnershipMarker = (resourceId: string | undefined) =>
137 (resourceId ?? "").startsWith("alch_") &&
138 Object.keys(parseOwnershipId(resourceId).labels).some((key) =>
139 key.startsWith("alchemy-"),
140 );
141
142const prefixMatch = (expected: string, observed: string) =>
143 expected === observed ||
144 expected.startsWith(observed) ||
145 observed.startsWith(expected);
146
147export const ownedByAlchemy = (id: string, resourceId: string | undefined) =>
148 Effect.gen(function* () {
149 if (!hasOwnershipMarker(resourceId)) return false;
150 const expected = yield* createInternalLabels(id);
151 const { labels } = parseOwnershipId(resourceId);
152 const exact = yield* hasAlchemyLabels(id, labels);
153 if (exact) return true;
154 return (
155 prefixMatch(
156 sanitizeIdPart(expected[alchemyLabelKeys.stack] ?? ""),
157 labels[alchemyLabelKeys.stack] ?? "",
158 ) &&
159 prefixMatch(
160 sanitizeIdPart(expected[alchemyLabelKeys.stage] ?? ""),
161 labels[alchemyLabelKeys.stage] ?? "",
162 ) &&
163 prefixMatch(
164 sanitizeIdPart(expected[alchemyLabelKeys.id] ?? ""),
165 labels[alchemyLabelKeys.id] ?? "",
166 )
167 );
168 });
169
170export const toDataPolicyId = (
171 id: string,
172 requested: string | undefined,
173 existing?: string,
174) =>
175 Effect.gen(function* () {
176 const labels = yield* createInternalLabels(id);
177 if (requested !== undefined && requested.length > 0) {
178 if (requested.startsWith("alch_")) return ensureId(requested);
179 return encodeOwnershipId(labels, requested);
180 }
181 if (existing !== undefined && existing.length > 0) return existing;
182 const physical = yield* createPhysicalName({
183 id,
184 maxLength: 40,
185 lowercase: true,
186 delimiter: "_",
187 });
188 return encodeOwnershipId(labels, physical);
189 });
190
191export const sameText = (left: string | undefined, right: string | undefined) =>
192 (left ?? "") === (right ?? "");
193
194const canonical = (value: unknown): unknown => {
195 if (value === undefined || value === null) return undefined;
196 if (typeof value === "boolean" || typeof value === "number") return value;
197 if (typeof value === "string") return value.length === 0 ? undefined : value;
198 if (Array.isArray(value)) {
199 const items = value.map(canonical).filter((item) => item !== undefined);
200 return items.length === 0 ? undefined : items;
201 }
202 if (typeof value === "object") {
203 const entries = Object.entries(value as Record<string, unknown>)
204 .map(([key, item]) => [key, canonical(item)] as const)
205 .filter(([, item]) => item !== undefined)
206 .sort(([left], [right]) => left.localeCompare(right));
207 if (entries.length === 0) return undefined;
208 return Object.fromEntries(entries);
209 }
210 return undefined;
211};
212
213export const sameJson = (left: unknown, right: unknown) =>
214 JSON.stringify(canonical(left) ?? null) ===
215 JSON.stringify(canonical(right) ?? null);
216
217export const sortedStrings = (values: readonly string[] | undefined) =>
218 [...(values ?? [])]
219 .map((value) => value.trim())
220 .filter(Boolean)
221 .sort();
222
223export const sameStringList = (
224 left: readonly string[] | undefined,
225 right: readonly string[] | undefined,
226) =>
227 JSON.stringify(sortedStrings(left)) === JSON.stringify(sortedStrings(right));
228
229export const updateMaskOf = (...fields: Array<string | undefined>) =>
230 fields.filter((field): field is string => field !== undefined).join(",");
231
232export const normalizePolicyType = (value: string | undefined) => {
233 const next = (value ?? DEFAULT_DATA_POLICY_TYPE).toUpperCase();
234 return next === "DATA_POLICY_TYPE_UNSPECIFIED"
235 ? DEFAULT_DATA_POLICY_TYPE
236 : next;
237};