1import * as discoveryengine from "@distilled.cloud/gcp/discoveryengine_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 identityMappingStoreIdOf,
13 LIST_LOCATIONS,
14 locationParent,
15 normalizeLocation,
16 parseResourceName,
17 toPhysical,
18} from "./internal.ts";
19import { waitForOperation } from "./operations.ts";
20
21export type IdentityMappingStoreProps = {
22 /**
23 * Identity Mapping Store id (the last path segment). If omitted, a
24 * unique id is generated from the stack, stage, and logical id.
25 * Immutable — changing it replaces the store. Generated ids are
26 * prefixed with `alch` so `list` / nuke can find them.
27 */
28 identityMappingStoreId?: string;
29 /**
30 * Location (`global`, `us`, `eu`). Immutable — changing it replaces
31 * the store.
32 * @default "global"
33 */
34 location?: string;
35 /**
36 * Cloud KMS key that protects this store at creation time. Immutable.
37 */
38 kmsKeyName?: string;
39 /**
40 * CMEK config resource name used to protect this store.
41 */
42 cmekConfigName?: string;
43 /**
44 * When true, skip CMEK even if the project has a default CmekConfig.
45 * @default false
46 */
47 disableCmek?: boolean;
48};
49
50export type IdentityMappingStore = Resource<
51 "GCP.DiscoveryEngine.IdentityMappingStore",
52 IdentityMappingStoreProps,
53 {
54 /** Full resource name `projects/{project}/locations/{location}/identityMappingStores/{id}`. */
55 name: string;
56 /** Store id (last path segment). */
57 identityMappingStoreId: string;
58 /** Project id. */
59 project: string;
60 /** Location id. */
61 location: string;
62 /** CMEK KMS key, if configured. */
63 kmsKey: string | undefined;
64 /** CMEK config resource name, if configured. */
65 cmekConfigName: string | undefined;
66 },
67 never,
68 Providers
69>;
70
71/**
72 * A Vertex AI Search Identity Mapping Store.
73 *
74 * Stores have no labels or display name, so generated ids are prefixed
75 * with `alch` for `list` / nuke. Location, id, and CMEK are identity —
76 * changing them replaces the store. There is nothing mutable to sync.
77 *
78 * ### Creating an Identity Mapping Store
79 * **Example:** Generated id
80 * ```typescript
81 * const store = yield* GCP.DiscoveryEngine.IdentityMappingStore("Users", {
82 * disableCmek: true,
83 * });
84 * ```
85 *
86 * **Example:** Explicit id and location
87 * ```typescript
88 * const store = yield* GCP.DiscoveryEngine.IdentityMappingStore("Users", {
89 * identityMappingStoreId: "app-users",
90 * location: "global",
91 * disableCmek: true,
92 * });
93 * ```
94 *
95 * @resource
96 * @category DiscoveryEngine
97 */
98export const IdentityMappingStore = Resource<IdentityMappingStore>(
99 "GCP.DiscoveryEngine.IdentityMappingStore",
100);
101
102export class IdentityMappingStoreNotResolved extends Data.TaggedError(
103 "GCP.DiscoveryEngine.IdentityMappingStoreNotResolved",
104)<{
105 name: string;
106}> {}
107
108const resourceName = (project: string, location: string, storeId: string) =>
109 `${locationParent(project, location)}/identityMappingStores/${storeId}`;
110
111const toAttrs = (
112 store: discoveryengine.GoogleCloudDiscoveryengineV1IdentityMappingStore,
113 project: string,
114) => {
115 const name = store.name ?? "";
116 const parsed = parseResourceName(name, "identityMappingStores");
117 return {
118 name,
119 identityMappingStoreId: parsed.id,
120 project: parsed.project || project,
121 location: parsed.location,
122 kmsKey: store.cmekConfig?.kmsKey ?? store.kmsKeyName,
123 cmekConfigName: store.cmekConfig?.name,
124 };
125};
126
127const getByName = (name: string) =>
128 name.length === 0
129 ? Effect.succeed(undefined)
130 : discoveryengine
131 .getProjectsLocationsIdentityMappingStores({ name })
132 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
133
134const isAlchemyId = (storeId: string) => storeId.startsWith("alch");
135
136const listStores = (project: string) =>
137 Effect.forEach(
138 LIST_LOCATIONS,
139 (location) =>
140 discoveryengine.listProjectsLocationsIdentityMappingStores
141 .pages({
142 parent: locationParent(project, location),
143 pageSize: 100,
144 })
145 .pipe(
146 Stream.flatMap((page) =>
147 Stream.fromIterable(page.identityMappingStores ?? []),
148 ),
149 Stream.filter((store) => isAlchemyId(lastId(store.name))),
150 Stream.runCollect,
151 Effect.map((chunk) => Array.from(chunk)),
152 Effect.catchTag("NotFound", () => Effect.succeed([])),
153 ),
154 { concurrency: 2 },
155 ).pipe(Effect.map((groups) => groups.flat()));
156
157const lastId = (name: string | undefined) =>
158 name
159 ? (name
160 .split("/")
161 .filter((part) => part.length > 0)
162 .at(-1) ?? "")
163 : "";
164
165export const IdentityMappingStoreProvider = () =>
166 Provider.succeed(IdentityMappingStore, {
167 stables: [
168 "name",
169 "identityMappingStoreId",
170 "project",
171 "location",
172 "kmsKey",
173 "cmekConfigName",
174 ],
175
176 diff: Effect.fn(function* ({ news, olds, output }) {
177 if (!isResolved(news)) return undefined;
178 const previousId =
179 olds?.identityMappingStoreId ?? output?.identityMappingStoreId;
180 const nextId = news.identityMappingStoreId ?? previousId;
181 const previousLocation = normalizeLocation(
182 olds?.location ?? output?.location,
183 );
184 const nextLocation = normalizeLocation(
185 news.location ?? olds?.location ?? output?.location,
186 );
187 const previousKey = olds?.kmsKeyName ?? output?.kmsKey ?? "";
188 const nextKey = news.kmsKeyName ?? previousKey;
189 const replace =
190 (previousId !== undefined &&
191 nextId !== undefined &&
192 nextId !== previousId) ||
193 previousLocation !== nextLocation ||
194 previousKey !== nextKey;
195 if (!replace) return undefined;
196 return {
197 action: "replace" as const,
198 deleteFirst:
199 previousLocation === nextLocation &&
200 previousId !== undefined &&
201 nextId === previousId,
202 };
203 }),
204
205 read: Effect.fn(function* ({ id, olds, output }) {
206 const env = yield* GcpEnvironment.current;
207 const storeId = yield* toPhysical(
208 id,
209 olds?.identityMappingStoreId,
210 output?.identityMappingStoreId,
211 identityMappingStoreIdOf,
212 );
213 const location = normalizeLocation(olds?.location ?? output?.location);
214 const name = output?.name ?? resourceName(env.project, location, storeId);
215 const existing = yield* getByName(name);
216 if (existing === undefined) return undefined;
217 const attrs = toAttrs(existing, env.project);
218 return isAlchemyId(attrs.identityMappingStoreId) ? attrs : Unowned(attrs);
219 }),
220
221 list: () =>
222 Effect.gen(function* () {
223 const env = yield* GcpEnvironment.current;
224 const stores = yield* listStores(env.project);
225 return stores.map((store) => toAttrs(store, env.project));
226 }),
227
228 reconcile: Effect.fn(function* ({ id, news, output }) {
229 const env = yield* GcpEnvironment.current;
230 const location = normalizeLocation(news.location ?? output?.location);
231 const storeId = yield* toPhysical(
232 id,
233 news.identityMappingStoreId,
234 output?.identityMappingStoreId,
235 identityMappingStoreIdOf,
236 );
237 const name = resourceName(env.project, location, storeId);
238
239 let current = yield* getByName(output?.name ?? name);
240
241 if (current === undefined) {
242 const created = yield* discoveryengine
243 .createProjectsLocationsIdentityMappingStores({
244 parent: locationParent(env.project, location),
245 identityMappingStoreId: storeId,
246 cmekConfigName: news.cmekConfigName,
247 disableCmek: news.disableCmek === true ? true : undefined,
248 body: {
249 name,
250 kmsKeyName: news.kmsKeyName,
251 },
252 })
253 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
254 current = created ?? undefined;
255 }
256
257 if (current === undefined) {
258 return yield* new IdentityMappingStoreNotResolved({ name });
259 }
260
261 return toAttrs(current, env.project);
262 }),
263
264 delete: Effect.fn(function* ({ output }) {
265 const existing = yield* getByName(output.name);
266 if (existing === undefined) return;
267 const operation = yield* discoveryengine
268 .deleteProjectsLocationsIdentityMappingStores({ name: output.name })
269 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
270 if (operation !== undefined) {
271 yield* waitForOperation(operation, { notFoundOk: true });
272 }
273 }),
274 });