1import * as Data from "effect/Data";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { createPhysicalName } from "../../PhysicalName.ts";
5import { isTransientGcpError } from "../Errors.ts";
6
7// Translation Advanced glossaries, AutoML models, and Adaptive MT live only in us-central1 (or global).
8export const DEFAULT_LOCATION = "us-central1";
9const MAX_DISPLAY_NAME_LENGTH = 32;
10const MAX_ID_LENGTH = 63;
11
12export class ResourceNotResolved extends Data.TaggedError(
13 "GCP.Translate.ResourceNotResolved",
14)<{
15 name: string;
16}> {}
17
18export class ResourceStillExists extends Data.TaggedError(
19 "GCP.Translate.ResourceStillExists",
20)<{
21 name: string;
22}> {}
23
24const lastSegment = (value: string) => {
25 const trimmed = value.replace(/\/+$/, "");
26 const parts = trimmed.split("/");
27 return parts[parts.length - 1] || trimmed;
28};
29
30export const normalizeLocation = (location: string | undefined) =>
31 lastSegment(location ?? DEFAULT_LOCATION).toLowerCase();
32
33export const locationParent = (project: string, location: string | undefined) =>
34 `projects/${project}/locations/${normalizeLocation(location)}`;
35
36export const locationParentOf = (name: string) => {
37 const parts = name.split("/").filter((part) => part.length > 0);
38 const locationsAt = parts.lastIndexOf("locations");
39 if (locationsAt >= 0 && parts[locationsAt + 1]) {
40 return parts.slice(0, locationsAt + 2).join("/");
41 }
42 return parts.slice(0, Math.max(0, parts.length - 2)).join("/");
43};
44
45export const parseResourceName = (name: string, collection: string) => {
46 const parts = name.split("/").filter((part) => part.length > 0);
47 const collectionAt = parts.lastIndexOf(collection);
48 const locationsAt = parts.lastIndexOf("locations");
49 const projectsAt = parts.lastIndexOf("projects");
50 return {
51 project:
52 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
53 location:
54 locationsAt >= 0 && parts[locationsAt + 1]
55 ? parts[locationsAt + 1]!
56 : DEFAULT_LOCATION,
57 id:
58 collectionAt >= 0 && parts[collectionAt + 1]
59 ? parts[collectionAt + 1]!
60 : lastSegment(name),
61 parent:
62 collectionAt > 0
63 ? parts.slice(0, collectionAt).join("/")
64 : parts.slice(0, Math.max(0, parts.length - 1)).join("/"),
65 };
66};
67
68export const expandParent = (
69 value: string,
70 project: string,
71 location: string,
72 collection: string,
73) => {
74 const trimmed = value.replace(/\/+$/, "");
75 if (trimmed.length === 0) {
76 return `${locationParent(project, location)}/${collection}`;
77 }
78 if (trimmed.includes("/")) return trimmed;
79 return `${locationParent(project, location)}/${collection}/${trimmed}`;
80};
81
82export const resourceNameOf = (
83 parent: string,
84 collection: string,
85 id: string,
86) => {
87 if (id.length === 0) return "";
88 if (id.includes(`/${collection}/`)) return id.replace(/\/+$/, "");
89 return `${parent}/${collection}/${lastSegment(id)}`;
90};
91
92export const sameText = (left: string | undefined, right: string | undefined) =>
93 (left ?? "") === (right ?? "");
94
95const canonical = (value: unknown): unknown => {
96 if (value === undefined || value === null) return undefined;
97 if (typeof value === "boolean" || typeof value === "number") return value;
98 if (typeof value === "string") {
99 return value.length === 0 ? undefined : value;
100 }
101 if (Array.isArray(value)) {
102 const items = value.map(canonical).filter((item) => item !== undefined);
103 return items.length === 0 ? undefined : items;
104 }
105 if (typeof value === "object") {
106 const entries = Object.entries(value as Record<string, unknown>)
107 .map(([key, item]) => [key, canonical(item)] as const)
108 .filter(([, item]) => item !== undefined)
109 .sort(([left], [right]) => left.localeCompare(right));
110 if (entries.length === 0) return undefined;
111 return Object.fromEntries(entries);
112 }
113 return undefined;
114};
115
116const fingerprint = (value: unknown): string =>
117 JSON.stringify(canonical(value) ?? null);
118
119export const sameJson = (left: unknown, right: unknown) =>
120 fingerprint(left) === fingerprint(right);
121
122export const replaceOnIdentity = (input: {
123 previousId?: string;
124 nextId?: string;
125 previousLocation?: string;
126 nextLocation?: string;
127 previousParent?: string;
128 nextParent?: string;
129 extra?: boolean;
130}) => {
131 const previousLocation =
132 input.previousLocation !== undefined
133 ? normalizeLocation(input.previousLocation)
134 : undefined;
135 const nextLocation =
136 input.nextLocation !== undefined
137 ? normalizeLocation(input.nextLocation)
138 : undefined;
139 const replace =
140 (input.extra ?? false) ||
141 (input.previousId !== undefined &&
142 input.nextId !== undefined &&
143 input.nextId !== input.previousId) ||
144 (previousLocation !== undefined &&
145 nextLocation !== undefined &&
146 previousLocation !== nextLocation) ||
147 (input.previousParent !== undefined &&
148 input.nextParent !== undefined &&
149 input.previousParent !== input.nextParent);
150 if (!replace) return undefined;
151 const samePhysical =
152 (previousLocation === undefined || previousLocation === nextLocation) &&
153 (input.previousParent === undefined ||
154 input.previousParent === input.nextParent) &&
155 input.previousId !== undefined &&
156 input.nextId === input.previousId;
157 return {
158 action: "replace" as const,
159 deleteFirst: samePhysical,
160 };
161};
162
163export const toPhysicalId = (
164 id: string,
165 explicit: string | undefined,
166 existing: string | undefined,
167 maxLength = MAX_ID_LENGTH,
168) =>
169 Effect.gen(function* () {
170 if (explicit !== undefined && explicit.length > 0) {
171 return lastSegment(explicit);
172 }
173 if (existing !== undefined && existing.length > 0) {
174 return existing;
175 }
176 return yield* createPhysicalName({
177 id,
178 maxLength,
179 lowercase: true,
180 });
181 });
182
183/**
184 * Adaptive MT datasets and custom models only accept display names of
185 * A-Z, a-z, 0-9, and underscore, max 32 characters.
186 */
187export const toRestrictedDisplayName = (
188 text: string,
189 maxLength = MAX_DISPLAY_NAME_LENGTH,
190): string =>
191 (text.replace(/[^A-Za-z0-9_]/g, "_").slice(0, maxLength) || "_").slice(
192 0,
193 maxLength,
194 );
195
196export const retryTransient = <A, E extends { readonly _tag: string }, R>(
197 effect: Effect.Effect<A, E, R>,
198) =>
199 effect.pipe(
200 Effect.retry({
201 while: isTransientGcpError,
202 times: 8,
203 schedule: Schedule.exponential("250 millis"),
204 }),
205 );
206
207export const waitUntilGone = <A, E extends { readonly _tag: string }, R>(
208 get: Effect.Effect<A | undefined, E, R>,
209 name: string,
210): Effect.Effect<void, E | ResourceStillExists, R> =>
211 get.pipe(
212 Effect.filterOrFail(
213 (value) => value === undefined,
214 () => new ResourceStillExists({ name }),
215 ),
216 Effect.retry({
217 while: (error) => error._tag === "GCP.Translate.ResourceStillExists",
218 times: 10,
219 schedule: Schedule.spaced("2 seconds"),
220 }),
221 Effect.asVoid,
222 );