1import * as rtb from "@distilled.cloud/gcp/realtimebidding_v1";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { createPhysicalName } from "../../PhysicalName.ts";
5import {
6 alchemyLabelKeys,
7 createInternalLabels,
8 hasAlchemyLabels,
9} from "../Labels.ts";
10
11export const MAX_DISPLAY_NAME_LENGTH = 255;
12export const PROBE_PARENT = "bidders/1";
13export const PROBE_NAME = `${PROBE_PARENT}/pretargetingConfigs/0`;
14
15export type TargetingMode =
16 | rtb.StringTargetingDimensionTargetingModeEnum
17 | (string & {});
18
19export type StringTargetingDimensionValue = {
20 targetingMode?: TargetingMode;
21 values?: string[];
22};
23
24export type NumericTargetingDimensionValue = {
25 includedIds?: string[];
26 excludedIds?: string[];
27};
28
29export type CreativeDimensionsValue = {
30 width?: string;
31 height?: string;
32};
33
34export type AppTargetingValue = {
35 mobileAppTargeting?: StringTargetingDimensionValue;
36 mobileAppCategoryTargeting?: NumericTargetingDimensionValue;
37};
38
39export type PretargetingState = rtb.PretargetingConfigStateEnum | (string & {});
40
41export type PretargetingSpec = {
42 allowedUserTargetingModes?: string[];
43 excludedContentLabelIds?: string[];
44 includedLanguages?: string[];
45 webTargeting?: StringTargetingDimensionValue;
46 includedPlatforms?: string[];
47 includedFormats?: string[];
48 maximumQps?: string;
49 geoTargeting?: NumericTargetingDimensionValue;
50 includedEnvironments?: string[];
51 userListTargeting?: NumericTargetingDimensionValue;
52 publisherTargeting?: StringTargetingDimensionValue;
53 includedUserIdTypes?: string[];
54 minimumViewabilityDecile?: number;
55 verticalTargeting?: NumericTargetingDimensionValue;
56 includedCreativeDimensions?: CreativeDimensionsValue[];
57 interstitialTargeting?: string;
58 appTargeting?: AppTargetingValue;
59 includedMobileOperatingSystemIds?: string[];
60};
61
62export const lastSegment = (value: string) => {
63 const trimmed = value.replace(/\/+$/, "");
64 const parts = trimmed.split("/");
65 return parts[parts.length - 1] || trimmed;
66};
67
68export const normalizeResourceName = (value: string) =>
69 value.replace(/\/+$/, "").trim();
70
71export const expandParent = (value: string) => {
72 const trimmed = normalizeResourceName(value);
73 if (trimmed.length === 0) return trimmed;
74 return trimmed.startsWith("bidders/") ? trimmed : `bidders/${trimmed}`;
75};
76
77export const parentOfName = (name: string) => {
78 const parts = name.split("/").filter((part) => part.length > 0);
79 const plural = parts.lastIndexOf("pretargetingConfigs");
80 const singular = parts.lastIndexOf("pretargetingConfig");
81 const index = Math.max(plural, singular);
82 if (index > 0) return parts.slice(0, index).join("/");
83 return "";
84};
85
86export const resourceName = (parent: string, configId: string) =>
87 `${expandParent(parent)}/pretargetingConfigs/${configId}`;
88
89export const configIdOf = (name: string) => lastSegment(name);
90
91export const sameText = (left: string | undefined, right: string | undefined) =>
92 (left ?? "") === (right ?? "");
93
94const canonical = (value: unknown): unknown => {
95 if (value === undefined || value === null) return undefined;
96 if (typeof value === "boolean" || typeof value === "number") return value;
97 if (typeof value === "string") {
98 return value.length === 0 ? undefined : value;
99 }
100 if (Array.isArray(value)) {
101 const items = value.map(canonical).filter((item) => item !== undefined);
102 if (items.length === 0) return undefined;
103 const allScalar = items.every(
104 (item) => item === undefined || typeof item !== "object",
105 );
106 return allScalar
107 ? [...items].sort((left, right) =>
108 String(left).localeCompare(String(right)),
109 )
110 : items;
111 }
112 if (typeof value === "object") {
113 const entries = Object.entries(value as Record<string, unknown>)
114 .map(([key, item]) => [key, canonical(item)] as const)
115 .filter(([, item]) => item !== undefined)
116 .sort(([left], [right]) => left.localeCompare(right));
117 if (entries.length === 0) return undefined;
118 return Object.fromEntries(entries);
119 }
120 return undefined;
121};
122
123export const fingerprint = (value: unknown): string =>
124 JSON.stringify(canonical(value) ?? null);
125
126export const jsonEqual = (left: unknown, right: unknown) =>
127 fingerprint(left) === fingerprint(right);
128
129export const replaceOnIdentity = (input: {
130 previousParent?: string;
131 nextParent: string;
132 previousId?: string;
133 nextId?: string;
134}) => {
135 if (
136 input.previousParent !== undefined &&
137 input.previousParent.length > 0 &&
138 input.nextParent.length > 0 &&
139 input.previousParent !== input.nextParent
140 ) {
141 return { action: "replace" as const, deleteFirst: false };
142 }
143 if (
144 input.previousId !== undefined &&
145 input.nextId !== undefined &&
146 input.previousId !== input.nextId
147 ) {
148 return { action: "replace" as const, deleteFirst: true };
149 }
150 return undefined;
151};
152
153const markerOf = (stack: string, stage: string, id: string) =>
154 `[alchemy ${alchemyLabelKeys.stack}=${stack} ${alchemyLabelKeys.stage}=${stage} ${alchemyLabelKeys.id}=${id}]`;
155
156export const fitMarker = (
157 labels: Record<string, string>,
158 maxLength: number,
159) => {
160 let stack = labels[alchemyLabelKeys.stack] ?? "x";
161 let stage = labels[alchemyLabelKeys.stage] ?? "x";
162 let id = labels[alchemyLabelKeys.id] ?? "x";
163 let marker = markerOf(stack, stage, id);
164 while (
165 marker.length > maxLength &&
166 (stack.length > 1 || stage.length > 1 || id.length > 1)
167 ) {
168 if (id.length >= stack.length && id.length >= stage.length) {
169 id = id.slice(0, -1);
170 } else if (stack.length >= stage.length) {
171 stack = stack.slice(0, -1);
172 } else {
173 stage = stage.slice(0, -1);
174 }
175 marker = markerOf(stack, stage, id);
176 }
177 return marker.slice(0, maxLength);
178};
179
180export const encodeDisplayName = (
181 labels: Record<string, string>,
182 displayName: string | undefined,
183 maxLength = MAX_DISPLAY_NAME_LENGTH,
184): string => {
185 const trimmed = displayName?.replace(/[\r\n]+/g, " ").trim();
186 if (!trimmed) return fitMarker(labels, maxLength);
187 const minMarker = 24;
188 const reserved = Math.min(
189 trimmed.length + 1,
190 Math.max(0, maxLength - minMarker),
191 );
192 const marker = fitMarker(labels, maxLength - reserved);
193 return `${marker} ${trimmed}`.slice(0, maxLength);
194};
195
196export const parseDisplayName = (
197 displayName: string | undefined,
198): {
199 labels: Record<string, string>;
200 displayName: string | undefined;
201} => {
202 if (!displayName?.startsWith("[alchemy ")) {
203 return { labels: {}, displayName };
204 }
205 const end = displayName.indexOf("]");
206 if (end < 0) return { labels: {}, displayName };
207 const labels: Record<string, string> = {};
208 for (const part of displayName.slice("[alchemy ".length, end).split(/\s+/)) {
209 const eq = part.indexOf("=");
210 if (eq > 0) {
211 labels[part.slice(0, eq)] = part.slice(eq + 1);
212 }
213 }
214 const rest = displayName.slice(end + 1).replace(/^[\s\n]+/, "");
215 return { labels, displayName: rest.length > 0 ? rest : undefined };
216};
217
218export const hasOwnershipMarker = (displayName: string | undefined) =>
219 Object.keys(parseDisplayName(displayName).labels).some((key) =>
220 key.startsWith("alchemy-"),
221 );
222
223const prefixMatch = (expected: string, observed: string) =>
224 expected === observed ||
225 expected.startsWith(observed) ||
226 observed.startsWith(expected);
227
228export const ownedByAlchemy = (id: string, displayName: string | undefined) =>
229 Effect.gen(function* () {
230 const expected = yield* createInternalLabels(id);
231 const { labels } = parseDisplayName(displayName);
232 if (!hasOwnershipMarker(displayName)) return false;
233 if (yield* hasAlchemyLabels(id, labels)) return true;
234 return (
235 prefixMatch(
236 expected[alchemyLabelKeys.stack] ?? "",
237 labels[alchemyLabelKeys.stack] ?? "",
238 ) &&
239 prefixMatch(
240 expected[alchemyLabelKeys.stage] ?? "",
241 labels[alchemyLabelKeys.stage] ?? "",
242 ) &&
243 prefixMatch(
244 expected[alchemyLabelKeys.id] ?? "",
245 labels[alchemyLabelKeys.id] ?? "",
246 )
247 );
248 });
249
250export const ownershipLabels = (id: string) => createInternalLabels(id);
251
252export const toDisplayName = (
253 id: string,
254 requested: string | undefined,
255 existing: string | undefined,
256) =>
257 Effect.gen(function* () {
258 if (requested !== undefined && requested.length > 0) {
259 return parseDisplayName(requested).displayName ?? requested;
260 }
261 if (existing !== undefined && existing.length > 0) {
262 return parseDisplayName(existing).displayName ?? existing;
263 }
264 const generated = yield* createPhysicalName({
265 id,
266 maxLength: 40,
267 lowercase: true,
268 });
269 return /^[a-z]/.test(generated) ? generated : `c${generated}`.slice(0, 40);
270 });
271
272const emptyList = <A>() => Effect.succeed([] as A[]);
273
274export const collectPages = <A, Page, E, R>(
275 pages: Stream.Stream<Page, E, R>,
276 pick: (page: Page) => readonly A[] | undefined,
277) =>
278 pages.pipe(
279 Stream.flatMap((page) => Stream.fromIterable(pick(page) ?? [])),
280 Stream.runCollect,
281 Effect.map((chunk) => Array.from(chunk)),
282 );
283
284const isMissing = <E extends { readonly _tag: string }>(
285 error: E,
286): error is Extract<E, { readonly _tag: "NotFound" }> =>
287 error._tag === "NotFound";
288
289export const ignoreList =
290 <A>(fallback: A) =>
291 <A1, E extends { readonly _tag: string }, R>(
292 self: Effect.Effect<A1, E, R>,
293 ): Effect.Effect<A1 | A, E, R> =>
294 self.pipe(Effect.catchIf(isMissing, () => Effect.succeed(fallback)));
295
296export const ignoreMissing = <E extends { readonly _tag: string }, R>(
297 effect: Effect.Effect<unknown, E, R>,
298) => effect.pipe(Effect.catchIf(isMissing, () => Effect.void));
299
300export const getConfig = (name: string) =>
301 name.length === 0
302 ? Effect.succeed(undefined)
303 : rtb
304 .getBiddersPretargetingConfigs({ name })
305 .pipe(Effect.catchIf(isMissing, () => Effect.succeed(undefined)));
306
307export const listConfigs = (parent: string) =>
308 parent.length === 0
309 ? emptyList<rtb.PretargetingConfig>()
310 : collectPages(
311 rtb.listBiddersPretargetingConfigs.pages({
312 parent,
313 pageSize: 100,
314 }),
315 (page) => page.pretargetingConfigs,
316 ).pipe(ignoreList([] as rtb.PretargetingConfig[]));
317
318export const listBidderAccounts = () =>
319 collectPages(
320 rtb.listBidders.pages({ pageSize: 100 }),
321 (page) => page.bidders,
322 ).pipe(ignoreList([] as rtb.Bidder[]));
323
324export const listParentsForNuke = () =>
325 Effect.gen(function* () {
326 const bidders = yield* listBidderAccounts();
327 const listed = bidders
328 .map((bidder) => bidder.name)
329 .filter((name): name is string => !!name)
330 .map(expandParent);
331 return [...new Set(listed)];
332 });
333
334export const listOwnedConfigs = () =>
335 Effect.gen(function* () {
336 const parents = yield* listParentsForNuke();
337 const pages = yield* Effect.forEach(parents, listConfigs, {
338 concurrency: 4,
339 });
340 return pages.flat().filter((row) => hasOwnershipMarker(row.displayName));
341 });
342
343export const findOwnedConfig = (
344 id: string,
345 parent?: string,
346 name?: string,
347 displayName?: string,
348) =>
349 Effect.gen(function* () {
350 const rows =
351 parent && parent.length > 0
352 ? yield* listConfigs(parent)
353 : yield* listOwnedConfigs();
354 if (name) {
355 const exact = rows.find((row) => row.name === name);
356 if (exact) return exact;
357 }
358 if (displayName) {
359 const named = rows.find((row) => sameText(row.displayName, displayName));
360 if (named) return named;
361 }
362 for (const row of rows) {
363 if (yield* ownedByAlchemy(id, row.displayName)) {
364 return row;
365 }
366 }
367 return undefined;
368 });
369
370const keep = <T>(news: T | undefined, current: T | undefined) =>
371 news !== undefined ? news : current;
372
373export const specOf = (input: PretargetingSpec): PretargetingSpec => ({
374 allowedUserTargetingModes: input.allowedUserTargetingModes,
375 excludedContentLabelIds: input.excludedContentLabelIds,
376 includedLanguages: input.includedLanguages,
377 webTargeting: input.webTargeting,
378 includedPlatforms: input.includedPlatforms,
379 includedFormats: input.includedFormats,
380 maximumQps: input.maximumQps,
381 geoTargeting: input.geoTargeting,
382 includedEnvironments: input.includedEnvironments,
383 userListTargeting: input.userListTargeting,
384 publisherTargeting: input.publisherTargeting,
385 includedUserIdTypes: input.includedUserIdTypes,
386 minimumViewabilityDecile: input.minimumViewabilityDecile,
387 verticalTargeting: input.verticalTargeting,
388 includedCreativeDimensions: input.includedCreativeDimensions,
389 interstitialTargeting: input.interstitialTargeting,
390 appTargeting: input.appTargeting,
391 includedMobileOperatingSystemIds: input.includedMobileOperatingSystemIds,
392});
393
394export const specFromRow = (row: rtb.PretargetingConfig): PretargetingSpec =>
395 specOf({
396 allowedUserTargetingModes: row.allowedUserTargetingModes,
397 excludedContentLabelIds: row.excludedContentLabelIds,
398 includedLanguages: row.includedLanguages,
399 webTargeting: row.webTargeting,
400 includedPlatforms: row.includedPlatforms,
401 includedFormats: row.includedFormats,
402 maximumQps: row.maximumQps,
403 geoTargeting: row.geoTargeting,
404 includedEnvironments: row.includedEnvironments,
405 userListTargeting: row.userListTargeting,
406 publisherTargeting: row.publisherTargeting,
407 includedUserIdTypes: row.includedUserIdTypes,
408 minimumViewabilityDecile: row.minimumViewabilityDecile,
409 verticalTargeting: row.verticalTargeting,
410 includedCreativeDimensions: row.includedCreativeDimensions,
411 interstitialTargeting: row.interstitialTargeting,
412 appTargeting: row.appTargeting,
413 includedMobileOperatingSystemIds: row.includedMobileOperatingSystemIds,
414 });
415
416export const mergeSpec = (
417 news: PretargetingSpec,
418 current: PretargetingSpec | undefined,
419): PretargetingSpec => ({
420 allowedUserTargetingModes: keep(
421 news.allowedUserTargetingModes,
422 current?.allowedUserTargetingModes,
423 ),
424 excludedContentLabelIds: keep(
425 news.excludedContentLabelIds,
426 current?.excludedContentLabelIds,
427 ),
428 includedLanguages: keep(news.includedLanguages, current?.includedLanguages),
429 webTargeting: keep(news.webTargeting, current?.webTargeting),
430 includedPlatforms: keep(news.includedPlatforms, current?.includedPlatforms),
431 includedFormats: keep(news.includedFormats, current?.includedFormats),
432 maximumQps: keep(news.maximumQps, current?.maximumQps),
433 geoTargeting: keep(news.geoTargeting, current?.geoTargeting),
434 includedEnvironments: keep(
435 news.includedEnvironments,
436 current?.includedEnvironments,
437 ),
438 userListTargeting: keep(news.userListTargeting, current?.userListTargeting),
439 publisherTargeting: keep(
440 news.publisherTargeting,
441 current?.publisherTargeting,
442 ),
443 includedUserIdTypes: keep(
444 news.includedUserIdTypes,
445 current?.includedUserIdTypes,
446 ),
447 minimumViewabilityDecile: keep(
448 news.minimumViewabilityDecile,
449 current?.minimumViewabilityDecile,
450 ),
451 verticalTargeting: keep(news.verticalTargeting, current?.verticalTargeting),
452 includedCreativeDimensions: keep(
453 news.includedCreativeDimensions,
454 current?.includedCreativeDimensions,
455 ),
456 interstitialTargeting: keep(
457 news.interstitialTargeting,
458 current?.interstitialTargeting,
459 ),
460 appTargeting: keep(news.appTargeting, current?.appTargeting),
461 includedMobileOperatingSystemIds: keep(
462 news.includedMobileOperatingSystemIds,
463 current?.includedMobileOperatingSystemIds,
464 ),
465});
466
467export const toConfigBody = (
468 displayName: string,
469 spec: PretargetingSpec,
470): rtb.PretargetingConfig => ({
471 displayName,
472 allowedUserTargetingModes: spec.allowedUserTargetingModes,
473 excludedContentLabelIds: spec.excludedContentLabelIds,
474 includedLanguages: spec.includedLanguages,
475 webTargeting: spec.webTargeting,
476 includedPlatforms: spec.includedPlatforms,
477 includedFormats: spec.includedFormats,
478 maximumQps: spec.maximumQps,
479 geoTargeting: spec.geoTargeting,
480 includedEnvironments: spec.includedEnvironments,
481 userListTargeting: spec.userListTargeting,
482 publisherTargeting: spec.publisherTargeting,
483 includedUserIdTypes: spec.includedUserIdTypes,
484 minimumViewabilityDecile: spec.minimumViewabilityDecile,
485 verticalTargeting: spec.verticalTargeting,
486 includedCreativeDimensions: spec.includedCreativeDimensions,
487 interstitialTargeting: spec.interstitialTargeting,
488 appTargeting: spec.appTargeting,
489 includedMobileOperatingSystemIds: spec.includedMobileOperatingSystemIds,
490});
491
492const PATCH_FIELDS = [
493 ["allowedUserTargetingModes", "allowedUserTargetingModes"],
494 ["excludedContentLabelIds", "excludedContentLabelIds"],
495 ["includedLanguages", "includedLanguages"],
496 ["webTargeting", "webTargeting"],
497 ["includedPlatforms", "includedPlatforms"],
498 ["includedFormats", "includedFormats"],
499 ["maximumQps", "maximumQps"],
500 ["geoTargeting", "geoTargeting"],
501 ["includedEnvironments", "includedEnvironments"],
502 ["userListTargeting", "userListTargeting"],
503 ["publisherTargeting", "publisherTargeting"],
504 ["includedUserIdTypes", "includedUserIdTypes"],
505 ["minimumViewabilityDecile", "minimumViewabilityDecile"],
506 ["verticalTargeting", "verticalTargeting"],
507 ["includedCreativeDimensions", "includedCreativeDimensions"],
508 ["interstitialTargeting", "interstitialTargeting"],
509 ["appTargeting", "appTargeting"],
510 ["includedMobileOperatingSystemIds", "includedMobileOperatingSystemIds"],
511] as const satisfies ReadonlyArray<
512 readonly [keyof PretargetingSpec, keyof PretargetingSpec]
513>;
514
515export const updateMaskOf = (
516 current: rtb.PretargetingConfig,
517 desired: PretargetingSpec,
518 displayName: string,
519) => {
520 const fields: string[] = [];
521 if (!sameText(current.displayName, displayName)) {
522 fields.push("displayName");
523 }
524 const observed = specFromRow(current);
525 for (const [key, mask] of PATCH_FIELDS) {
526 if (!jsonEqual(observed[key], desired[key])) {
527 fields.push(mask);
528 }
529 }
530 return fields.join(",");
531};