1import * as transcoder from "@distilled.cloud/gcp/transcoder_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import { tagRecord } from "../../Tags.ts";
8import {
9 ALCHEMY_LABEL_PREFIX,
10 createInternalLabels,
11 hasAlchemyLabels,
12 stripInternalLabels,
13 toLabels,
14} from "../Labels.ts";
15
16export const MAX_ID_LENGTH = 63;
17export const MIN_ID_LENGTH = 4;
18export const LIST_LOCATIONS = [
19 "us-central1",
20 "us-east1",
21 "us-west1",
22 "europe-west1",
23 "asia-east1",
24] as const;
25
26export class JobTemplateNotResolved extends Data.TaggedError(
27 "GCP.Transcoder.JobTemplateNotResolved",
28)<{
29 name: string;
30}> {}
31
32export class JobTemplateStillExists extends Data.TaggedError(
33 "GCP.Transcoder.JobTemplateStillExists",
34)<{
35 name: string;
36}> {}
37
38export const lastSegment = (value: string) => {
39 const trimmed = value.replace(/\/+$/, "");
40 const parts = trimmed.split("/");
41 return parts[parts.length - 1] || trimmed;
42};
43
44export const normalizeLocation = (
45 location: string | undefined,
46 defaultLocation: string,
47) => lastSegment(location ?? defaultLocation).toLowerCase();
48
49export const locationParent = (project: string, location: string) =>
50 `projects/${project}/locations/${location}`;
51
52export const resourceName = (
53 project: string,
54 location: string,
55 jobTemplateId: string,
56) => `${locationParent(project, location)}/jobTemplates/${jobTemplateId}`;
57
58export const parseName = (name: string, defaultLocation: string) => {
59 const parts = name.split("/").filter((part) => part.length > 0);
60 const templatesAt = parts.lastIndexOf("jobTemplates");
61 const locationsAt = parts.lastIndexOf("locations");
62 const projectsAt = parts.lastIndexOf("projects");
63 return {
64 project:
65 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
66 location:
67 locationsAt >= 0 && parts[locationsAt + 1]
68 ? parts[locationsAt + 1]!
69 : defaultLocation,
70 jobTemplateId:
71 templatesAt >= 0 && parts[templatesAt + 1]
72 ? parts[templatesAt + 1]!
73 : lastSegment(name),
74 parent:
75 templatesAt > 0
76 ? parts.slice(0, templatesAt).join("/")
77 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
78 };
79};
80
81export const parentOfName = (name: string, defaultLocation: string) => {
82 const parsed = parseName(name, defaultLocation);
83 return parsed.parent.length > 0
84 ? parsed.parent
85 : locationParent(parsed.project, parsed.location);
86};
87
88/** Job template ids are 4-63 chars matching `[a-zA-Z][a-zA-Z0-9_-]*`. */
89export const rfc1035 = (
90 name: string,
91 maxLength = MAX_ID_LENGTH,
92 fallback = "tmpl",
93): string => {
94 let next = name
95 .toLowerCase()
96 .replace(/[^a-z0-9_-]/g, "-")
97 .replace(/[-_]{2,}/g, "-")
98 .replace(/^[-_]+|[-_]+$/g, "");
99 if (!/^[a-z]/.test(next)) next = `t${next}`;
100 next = next.slice(0, maxLength).replace(/[-_]+$/g, "");
101 if (next.length === 0) return fallback.slice(0, maxLength);
102 if (next.length < MIN_ID_LENGTH) {
103 next = `${next}${fallback}`.slice(0, maxLength);
104 }
105 if (!/[a-z0-9]$/.test(next)) {
106 next = `${next.slice(0, maxLength - 1)}0`;
107 }
108 return next.slice(0, maxLength);
109};
110
111export const toJobTemplateId = (
112 id: string,
113 requested: string | undefined,
114 existing: string | undefined,
115) =>
116 Effect.gen(function* () {
117 if (requested !== undefined && requested.length > 0) {
118 return rfc1035(requested);
119 }
120 if (existing !== undefined && existing.length > 0) return existing;
121 return rfc1035(
122 yield* createPhysicalName({
123 id,
124 maxLength: MAX_ID_LENGTH,
125 lowercase: true,
126 }),
127 );
128 });
129
130export const userLabels = (
131 labels: Record<string, string | undefined> | null | undefined,
132): Record<string, string> => stripInternalLabels(tagRecord(labels));
133
134export const desiredLabelsOf = (
135 id: string,
136 labels: Record<string, string> | undefined,
137) =>
138 Effect.gen(function* () {
139 return {
140 ...toLabels(labels),
141 ...(yield* createInternalLabels(id)),
142 };
143 });
144
145export const hasOwnershipMarker = (
146 labels: Record<string, string | undefined> | null | undefined,
147) =>
148 Object.keys(labels ?? {}).some((key) => key.startsWith(ALCHEMY_LABEL_PREFIX));
149
150export const ownedByAlchemy = (
151 id: string,
152 labels: Record<string, string | undefined> | null | undefined,
153) => hasAlchemyLabels(id, tagRecord(labels));
154
155const stable = (value: unknown): unknown => {
156 if (Array.isArray(value)) return value.map(stable);
157 if (value !== null && typeof value === "object") {
158 return Object.fromEntries(
159 Object.entries(value as Record<string, unknown>)
160 .filter(([, item]) => item !== undefined)
161 .sort(([left], [right]) => left.localeCompare(right))
162 .map(([key, item]) => [key, stable(item)]),
163 );
164 }
165 return value;
166};
167
168export const jsonKey = (value: unknown) =>
169 JSON.stringify(stable(value ?? null));
170
171export const labelsKey = (labels: Record<string, string> | undefined): string =>
172 jsonKey(toLabels(labels));
173
174export const configKey = (config: transcoder.JobConfig | undefined): string =>
175 jsonKey(config);
176
177export const getJobTemplate = (name: string) =>
178 name.length === 0
179 ? Effect.succeed(undefined)
180 : transcoder
181 .getProjectsLocationsJobTemplates({ name })
182 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
183
184export const waitUntilGone = (name: string) =>
185 getJobTemplate(name).pipe(
186 Effect.flatMap((template) =>
187 template === undefined
188 ? Effect.void
189 : Effect.fail(new JobTemplateStillExists({ name })),
190 ),
191 Effect.retry({
192 while: (error) => error._tag === "GCP.Transcoder.JobTemplateStillExists",
193 times: 8,
194 schedule: Schedule.spaced("1 second"),
195 }),
196 );
197
198const emptyList = () => Effect.succeed([] as transcoder.JobTemplate[]);
199
200export const listJobTemplatesAt = (parent: string) =>
201 transcoder.listProjectsLocationsJobTemplates
202 .pages({
203 parent,
204 pageSize: 1000,
205 })
206 .pipe(
207 Stream.flatMap((page) => Stream.fromIterable(page.jobTemplates ?? [])),
208 Stream.runCollect,
209 Effect.map((chunk) => Array.from(chunk)),
210 Effect.catchTag("NotFound", () => emptyList()),
211 );
212
213export const listOwnedJobTemplates = (project: string, region: string) =>
214 Effect.gen(function* () {
215 const pages = yield* Effect.forEach(
216 [...new Set<string>([region, ...LIST_LOCATIONS])],
217 (location) => listJobTemplatesAt(locationParent(project, location)),
218 { concurrency: 4 },
219 );
220 const seen = new Set<string>();
221 const owned: transcoder.JobTemplate[] = [];
222 for (const row of pages.flat()) {
223 const name = row.name ?? "";
224 if (name.length > 0 && seen.has(name)) continue;
225 if (!hasOwnershipMarker(row.labels)) continue;
226 if (name.length > 0) seen.add(name);
227 owned.push(row);
228 }
229 return owned;
230 });
231
232export const findOwnedJobTemplate = (
233 id: string,
234 project: string,
235 region: string,
236 name?: string,
237) =>
238 Effect.gen(function* () {
239 const existing = yield* getJobTemplate(name ?? "");
240 if (existing !== undefined) return existing;
241 const rows = yield* listOwnedJobTemplates(project, region);
242 for (const row of rows) {
243 if (yield* ownedByAlchemy(id, row.labels)) {
244 return row;
245 }
246 }
247 return undefined;
248 });