1import * as vision from "@distilled.cloud/gcp/vision_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";
7
8// Product Search only exists in PRODUCT_SEARCH_LOCATIONS, so the stack region is not a usable default.
9export const DEFAULT_LOCATION = "us-west1";
10export const DEFAULT_PRODUCT_CATEGORY = "homegoods-v2";
11const MAX_ID_LENGTH = 128;
12
13const lastSegment = (value: string) => {
14 const trimmed = value.replace(/\/+$/, "");
15 const parts = trimmed.split("/");
16 return parts[parts.length - 1] || trimmed;
17};
18
19const parentOf = (name: string) => {
20 const parts = name.split("/").filter((part) => part.length > 0);
21 return parts.slice(0, -2).join("/");
22};
23
24export const normalizeLocation = (location: string | undefined) =>
25 lastSegment(location ?? DEFAULT_LOCATION).toLowerCase();
26
27export const locationParent = (project: string, location: string) =>
28 `projects/${project}/locations/${location}`;
29
30export const productSetNameOf = (
31 project: string,
32 location: string,
33 productSetId: string,
34) => {
35 if (productSetId.length === 0) return "";
36 if (productSetId.includes("/productSets/")) {
37 return productSetId.replace(/\/+$/, "");
38 }
39 return `${locationParent(project, location)}/productSets/${lastSegment(productSetId)}`;
40};
41
42export const productNameOf = (
43 project: string,
44 location: string,
45 productId: string,
46) => {
47 if (productId.length === 0) return "";
48 if (productId.includes("/products/")) {
49 return productId.replace(/\/+$/, "");
50 }
51 return `${locationParent(project, location)}/products/${lastSegment(productId)}`;
52};
53
54export const referenceImageNameOf = (parent: string, imageId: string) => {
55 if (imageId.length === 0) return "";
56 if (imageId.includes("/referenceImages/")) {
57 return imageId.replace(/\/+$/, "");
58 }
59 return `${parent}/referenceImages/${lastSegment(imageId)}`;
60};
61
62const expandProductName = (project: string, location: string, value: string) =>
63 productNameOf(project, location, value);
64
65export const parseResourceName = (
66 name: string,
67 fallbackProject: string,
68 collection: string,
69) => {
70 const parts = name.split("/").filter((part) => part.length > 0);
71 const collectionAt = parts.lastIndexOf(collection);
72 const locationsAt = parts.lastIndexOf("locations");
73 const projectsAt = parts.lastIndexOf("projects");
74 return {
75 name,
76 project:
77 projectsAt >= 0 && parts[projectsAt + 1]
78 ? parts[projectsAt + 1]!
79 : fallbackProject,
80 location:
81 locationsAt >= 0 && parts[locationsAt + 1]
82 ? parts[locationsAt + 1]!
83 : DEFAULT_LOCATION,
84 id:
85 collectionAt >= 0 && parts[collectionAt + 1]
86 ? parts[collectionAt + 1]!
87 : lastSegment(name),
88 parent:
89 collectionAt >= 0
90 ? parts.slice(0, collectionAt).join("/")
91 : parentOf(name),
92 };
93};
94
95export const sameText = (left: string | undefined, right: string | undefined) =>
96 (left ?? "") === (right ?? "");
97
98export const jsonEqual = (left: unknown, right: unknown) =>
99 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
100
101export const updateMaskOf = (...fields: Array<string | undefined>) =>
102 fields.filter((field): field is string => field !== undefined).join(",");
103
104export const replaceOnIdentity = (input: {
105 previousId?: string;
106 nextId?: string;
107 previousParent?: string;
108 nextParent?: string;
109 extra?: boolean;
110}) => {
111 if (input.extra === true) {
112 return { action: "replace" as const, deleteFirst: false };
113 }
114 if (
115 input.previousId !== undefined &&
116 input.nextId !== undefined &&
117 input.previousId !== input.nextId
118 ) {
119 return { action: "replace" as const, deleteFirst: false };
120 }
121 if (
122 input.previousParent !== undefined &&
123 input.nextParent !== undefined &&
124 input.previousParent !== input.nextParent
125 ) {
126 return { action: "replace" as const, deleteFirst: false };
127 }
128 return undefined;
129};
130
131export const toResourceId = (
132 id: string,
133 requested: string | undefined,
134 existing: string | undefined,
135 maxLength = MAX_ID_LENGTH,
136) =>
137 Effect.gen(function* () {
138 if (requested !== undefined && requested.length > 0) {
139 return lastSegment(requested).slice(0, maxLength);
140 }
141 if (existing !== undefined && existing.length > 0) {
142 return lastSegment(existing).slice(0, maxLength);
143 }
144 const generated = yield* createPhysicalName({
145 id,
146 maxLength,
147 lowercase: true,
148 });
149 const next = /^[a-z]/.test(generated)
150 ? generated
151 : `v${generated}`.slice(0, maxLength);
152 return next.length >= 4 ? next : `${next}xxxx`.slice(0, maxLength);
153 });
154
155const emptyList = <A>() => Effect.succeed([] as A[]);
156
157const catchMissing = <A, E extends { readonly _tag: string }, R>(
158 effect: Effect.Effect<A, E, R>,
159) =>
160 effect.pipe(
161 Effect.catchIf(
162 (error): error is E & { readonly _tag: "NotFound" } =>
163 error._tag === "NotFound",
164 () => Effect.succeed(undefined),
165 ),
166 );
167
168const ignoreMissing = <E extends { readonly _tag: string }, R>(
169 effect: Effect.Effect<unknown, E, R>,
170) =>
171 effect.pipe(
172 Effect.catchIf(
173 (error): error is E & { readonly _tag: "NotFound" } =>
174 error._tag === "NotFound",
175 () => Effect.void,
176 ),
177 );
178
179export class DeleteNotConfirmed extends Data.TaggedError(
180 "GCP.Vision.DeleteNotConfirmed",
181)<{}> {}
182
183/** Poll until the resource is gone; fails if it is still readable after ~60s. */
184export const waitUntilGone = <A, E, R>(
185 get: Effect.Effect<A | undefined, E, R>,
186) =>
187 get.pipe(
188 Effect.repeat({
189 schedule: Schedule.spaced("2 seconds"),
190 until: (value) => value === undefined,
191 times: 30,
192 }),
193 Effect.flatMap((value): Effect.Effect<void, DeleteNotConfirmed> =>
194 value === undefined ? Effect.void : Effect.fail(new DeleteNotConfirmed()),
195 ),
196 );
197
198const collectPages = <Page, Item, E extends { readonly _tag: string }, R>(
199 pages: Stream.Stream<Page, E, R>,
200 items: (page: Page) => readonly Item[] | null | undefined,
201) =>
202 pages.pipe(
203 Stream.flatMap((page) => Stream.fromIterable(items(page) ?? [])),
204 Stream.runCollect,
205 Effect.map((chunk) => Array.from(chunk) as Item[]),
206 Effect.catchIf(
207 (error): error is E & { readonly _tag: "NotFound" } =>
208 error._tag === "NotFound",
209 () => emptyList<Item>(),
210 ),
211 );
212
213export const getProductSet = (name: string) =>
214 name.length === 0
215 ? Effect.succeed(undefined)
216 : catchMissing(vision.getProjectsLocationsProductSets({ name }));
217
218export const getProduct = (name: string) =>
219 name.length === 0
220 ? Effect.succeed(undefined)
221 : catchMissing(vision.getProjectsLocationsProducts({ name }));
222
223export const getReferenceImage = (name: string) =>
224 name.length === 0
225 ? Effect.succeed(undefined)
226 : catchMissing(
227 vision.getProjectsLocationsProductsReferenceImages({ name }),
228 );
229
230export const listProductsInSet = (name: string) =>
231 name.length === 0
232 ? emptyList<vision.Product>()
233 : collectPages(
234 vision.listProjectsLocationsProductSetsProducts.pages({
235 name,
236 pageSize: 100,
237 }),
238 (page) => page.products,
239 );
240
241export const productLabelsOf = (
242 labels: ReadonlyArray<{ key?: string; value?: string }> | undefined,
243): Array<{ key: string; value: string }> =>
244 (labels ?? []).flatMap((item) =>
245 item.key ? [{ key: item.key, value: item.value ?? "" }] : [],
246 );
247
248export const sameProductLabels = (
249 left: ReadonlyArray<{ key: string; value: string }> | undefined,
250 right: ReadonlyArray<{ key: string; value: string }> | undefined,
251) =>
252 jsonEqual(
253 [...(left ?? [])].map((item) => `${item.key}=${item.value}`).sort(),
254 [...(right ?? [])].map((item) => `${item.key}=${item.value}`).sort(),
255 );
256
257export const deleteProductSet = (name: string) =>
258 name.length === 0
259 ? Effect.void
260 : ignoreMissing(vision.deleteProjectsLocationsProductSets({ name }));
261
262export const deleteProduct = (name: string) =>
263 name.length === 0
264 ? Effect.void
265 : ignoreMissing(vision.deleteProjectsLocationsProducts({ name }));
266
267export const deleteReferenceImage = (name: string) =>
268 name.length === 0
269 ? Effect.void
270 : ignoreMissing(
271 vision.deleteProjectsLocationsProductsReferenceImages({ name }),
272 );
273
274export const syncProductSetMembership = (
275 setName: string,
276 project: string,
277 location: string,
278 desired: readonly string[] | undefined,
279) =>
280 Effect.gen(function* () {
281 if (desired === undefined) return;
282 const wanted = new Set(
283 desired
284 .filter((value) => value.length > 0)
285 .map((value) => expandProductName(project, location, value)),
286 );
287 const observed = yield* listProductsInSet(setName);
288 const present = new Set(
289 observed
290 .map((product) => product.name)
291 .filter((name): name is string => typeof name === "string"),
292 );
293 for (const product of wanted) {
294 if (present.has(product)) continue;
295 yield* vision
296 .addProductProjectsLocationsProductSets({
297 name: setName,
298 body: { product },
299 })
300 .pipe(Effect.catchTag("Conflict", () => Effect.void));
301 }
302 for (const product of present) {
303 if (wanted.has(product)) continue;
304 yield* vision
305 .removeProductProjectsLocationsProductSets({
306 name: setName,
307 body: { product },
308 })
309 .pipe(Effect.catchTag("NotFound", () => Effect.void));
310 }
311 });