1import * as vision from "@distilled.cloud/gcp/vision_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 DEFAULT_PRODUCT_CATEGORY,
12 deleteProduct,
13 getProduct,
14 locationParent,
15 normalizeLocation,
16 parseResourceName,
17 productLabelsOf,
18 productNameOf,
19 replaceOnIdentity,
20 sameProductLabels,
21 sameText,
22 toResourceId,
23 updateMaskOf,
24 waitUntilGone,
25} from "./internal.ts";
26
27export type ProductLabel = {
28 /** Label key (max 128 bytes). */
29 key: string;
30 /** Label value (max 128 bytes). */
31 value: string;
32};
33
34export type ProductProps = {
35 /**
36 * Product Search location. Immutable — changing it replaces the
37 * product.
38 * @default "us-west1"
39 */
40 location?: string;
41 /**
42 * Product id (the `{product}` segment of
43 * `projects/{project}/locations/{location}/products/{product}`). If
44 * omitted, a unique id is generated. Immutable — changing it replaces
45 * the product. At most 128 characters; cannot contain `/`.
46 */
47 productId?: string;
48 /**
49 * User-facing name (max 4096 characters).
50 * @default the product id
51 */
52 displayName?: string;
53 /**
54 * User-provided metadata (max 4096 characters).
55 */
56 description?: string;
57 /**
58 * Immutable product category. One of `homegoods-v2`, `apparel-v2`,
59 * `toys-v2`, `packagedgoods-v1`, or `general-v1`. Changing it replaces
60 * the product.
61 * @default "homegoods-v2"
62 */
63 productCategory?: string;
64 /**
65 * Search labels used to filter Product Search results.
66 */
67 productLabels?: ProductLabel[];
68};
69
70export type Product = Resource<
71 "GCP.Vision.Product",
72 ProductProps,
73 {
74 /** Full resource name `projects/{project}/locations/{location}/products/{product}`. */
75 name: string;
76 /** Product id (last path segment). */
77 productId: string;
78 /** Project id. */
79 project: string;
80 /** Product Search location. */
81 location: string;
82 /** User-facing display name. */
83 displayName: string | undefined;
84 /** User-provided metadata. */
85 description: string | undefined;
86 /** Product category. */
87 productCategory: string | undefined;
88 /** Product search labels. */
89 productLabels: ProductLabel[];
90 },
91 never,
92 Providers
93>;
94
95/**
96 * A Cloud Vision Product Search product. Products hold reference images
97 * and can be added to one or more product sets.
98 *
99 * Products have no labels field (`productLabels` are search filters), so
100 * ownership rests on the deterministic product id: a product found without
101 * prior state is reported as unowned and only taken over with `--adopt`.
102 * Location, product id, and `productCategory` are identity — changing any
103 * of them replaces the product. Display name, description, and labels
104 * update in place.
105 *
106 * ### Creating a Product
107 * **Example:** Generated id
108 * ```typescript
109 * const product = yield* GCP.Vision.Product("Shoe", {
110 * displayName: "Trail runner",
111 * productCategory: "apparel-v2",
112 * });
113 * ```
114 *
115 * **Example:** Explicit id and labels
116 * ```typescript
117 * const product = yield* GCP.Vision.Product("Shoe", {
118 * location: "us-west1",
119 * productId: "trail-runner",
120 * displayName: "Trail runner",
121 * productCategory: "apparel-v2",
122 * productLabels: [{ key: "color", value: "blue" }],
123 * });
124 * ```
125 *
126 * ### Updating a Product
127 * **Example:** Rename and add a label
128 * ```typescript
129 * // Same logical id as before; only the changed props differ.
130 * const product = yield* GCP.Vision.Product("Shoe", {
131 * location: "us-west1",
132 * productId: "trail-runner",
133 * displayName: "Trail runner v2",
134 * productCategory: "apparel-v2",
135 * productLabels: [{ key: "color", value: "green" }],
136 * });
137 * ```
138 *
139 * @resource
140 * @category Vision
141 */
142export const Product = Resource<Product>("GCP.Vision.Product");
143
144export class ProductNotResolved extends Data.TaggedError(
145 "GCP.Vision.ProductNotResolved",
146)<{
147 name: string;
148}> {}
149
150const toAttrs = (product: vision.Product, project: string) => {
151 const name = product.name ?? "";
152 const parsed = parseResourceName(name, project, "products");
153 return {
154 name,
155 productId: parsed.id,
156 project: parsed.project || project,
157 location: parsed.location,
158 displayName: product.displayName,
159 description: product.description,
160 productCategory: product.productCategory,
161 productLabels: productLabelsOf(product.productLabels),
162 };
163};
164
165export const ProductProvider = () =>
166 Provider.succeed(Product, {
167 stables: ["name", "productId", "project", "location", "productCategory"],
168
169 diff: Effect.fn(function* ({ news, olds, output }) {
170 if (!isResolved(news)) return undefined;
171 const env = yield* GcpEnvironment.current;
172 const previousCategory = olds?.productCategory ?? output?.productCategory;
173 const nextCategory =
174 news.productCategory ?? previousCategory ?? DEFAULT_PRODUCT_CATEGORY;
175 return replaceOnIdentity({
176 previousId: olds?.productId ?? output?.productId,
177 nextId: news.productId,
178 previousParent: locationParent(
179 env.project,
180 normalizeLocation(olds?.location ?? output?.location),
181 ),
182 nextParent: locationParent(
183 env.project,
184 normalizeLocation(news.location ?? output?.location),
185 ),
186 extra:
187 previousCategory !== undefined && previousCategory !== nextCategory,
188 });
189 }),
190
191 read: Effect.fn(function* ({ olds, output }) {
192 const env = yield* GcpEnvironment.current;
193 const location = normalizeLocation(olds?.location ?? output?.location);
194 const name =
195 output?.name ??
196 productNameOf(
197 env.project,
198 location,
199 olds?.productId ?? output?.productId ?? "",
200 );
201 const existing = yield* getProduct(name);
202 if (existing === undefined) return undefined;
203 const attrs = toAttrs(existing, env.project);
204 // No labels field: without prior state it may not be ours.
205 return output !== undefined ? attrs : Unowned(attrs);
206 }),
207
208 reconcile: Effect.fn(function* ({ id, news, output }) {
209 const env = yield* GcpEnvironment.current;
210 const location = normalizeLocation(news.location ?? output?.location);
211 const productId = yield* toResourceId(
212 id,
213 news.productId,
214 output?.productId,
215 );
216 const displayName = news.displayName ?? productId;
217 const description = news.description;
218 const productCategory =
219 news.productCategory ??
220 output?.productCategory ??
221 DEFAULT_PRODUCT_CATEGORY;
222 const productLabels = productLabelsOf(news.productLabels);
223 const name =
224 output?.name ?? productNameOf(env.project, location, productId);
225
226 let current = yield* getProduct(name);
227
228 if (current === undefined) {
229 const created = yield* vision
230 .createProjectsLocationsProducts({
231 parent: locationParent(env.project, location),
232 productId,
233 body: {
234 displayName,
235 description,
236 productCategory,
237 productLabels,
238 },
239 })
240 .pipe(Effect.catchTag("Conflict", () => getProduct(name)));
241 current = created ?? undefined;
242 }
243
244 if (current === undefined) {
245 return yield* new ProductNotResolved({
246 name: name || productNameOf(env.project, location, productId),
247 });
248 }
249
250 const currentName = current.name ?? name;
251 const observedLabels = productLabelsOf(current.productLabels);
252 const updateMask = updateMaskOf(
253 sameText(current.displayName, displayName) ? undefined : "display_name",
254 sameText(current.description, description) ? undefined : "description",
255 sameProductLabels(observedLabels, productLabels)
256 ? undefined
257 : "product_labels",
258 );
259
260 if (updateMask.length > 0) {
261 current = yield* vision.patchProjectsLocationsProducts({
262 name: currentName,
263 updateMask,
264 body: {
265 name: currentName,
266 displayName,
267 description,
268 productLabels,
269 },
270 });
271 }
272
273 return toAttrs(current, env.project);
274 }),
275
276 delete: Effect.fn(function* ({ output }) {
277 if (!output.name) return;
278 yield* deleteProduct(output.name);
279 yield* waitUntilGone(getProduct(output.name));
280 }),
281 });