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 { alchemyLabelKeys, hasAlchemyLabels } from "../Labels.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 expandDataStore,
14 internalLabels,
15 listProjectDataStores,
16 parseResourceName,
17 sameStringList,
18 sessionIdOf,
19 toPhysical,
20} from "./internal.ts";
21
22export type DataStoresSessionProps = {
23 /**
24 * Parent Data Store resource name
25 * `projects/{project}/locations/{location}/dataStores/{dataStore}`.
26 * Immutable — changing it replaces the session.
27 */
28 dataStore: string;
29 /**
30 * Session id (1-63 characters, lowercase alphanumeric). If omitted, a
31 * unique id is generated. Immutable — changing it replaces the session.
32 */
33 sessionId?: string;
34 /**
35 * Display name shown in the UI.
36 */
37 displayName?: string;
38 /**
39 * Unique identifier for tracking users.
40 */
41 userPseudoId?: string;
42 /**
43 * Session state (`IN_PROGRESS`, `STATE_UNSPECIFIED`).
44 */
45 state?: string;
46 /**
47 * When true, the session is pinned to the top of the list.
48 * @default false
49 */
50 isPinned?: boolean;
51 /**
52 * User labels (string values). Alchemy adds `alchemy-stack=…`,
53 * `alchemy-stage=…`, and `alchemy-id=…` entries to mark ownership.
54 */
55 labels?: string[];
56};
57
58export type DataStoresSession = Resource<
59 "GCP.DiscoveryEngine.DataStoresSession",
60 DataStoresSessionProps,
61 {
62 /** Full resource name `.../dataStores/{dataStore}/sessions/{session}`. */
63 name: string;
64 /** Session id (last path segment). */
65 sessionId: string;
66 /** Parent data store resource name. */
67 dataStore: string;
68 /** Project id. */
69 project: string;
70 /** Location id. */
71 location: string;
72 /** Display name. */
73 displayName: string | undefined;
74 /** User pseudo id. */
75 userPseudoId: string | undefined;
76 /** Session state. */
77 state: string | undefined;
78 /** Whether the session is pinned. */
79 isPinned: boolean;
80 /** User labels (without Alchemy's `alchemy-*` entries). */
81 labels: string[];
82 /** RFC3339 start timestamp. */
83 startTime: string | undefined;
84 /** RFC3339 end timestamp. */
85 endTime: string | undefined;
86 },
87 never,
88 Providers
89>;
90
91/**
92 * A Vertex AI Search Session attached to a Data Store.
93 *
94 * Alchemy marks ownership with `alchemy-*` entries in the session's
95 * string-list `labels`. Parent and session id are immutable; display name, pin, user id, and labels
96 * update in place.
97 *
98 * ### Creating a Session
99 * **Example:** Generated id
100 * ```typescript
101 * const session = yield* GCP.DiscoveryEngine.DataStoresSession("Chat", {
102 * dataStore: dataStore.name,
103 * displayName: "support",
104 * });
105 * ```
106 *
107 * ### Updating a Session
108 * **Example:** Pin and rename
109 * ```typescript
110 * // Same logical id as before; only the changed props differ.
111 * const session = yield* GCP.DiscoveryEngine.DataStoresSession("Chat", {
112 * dataStore: dataStore.name,
113 * displayName: "vip-support",
114 * isPinned: true,
115 * });
116 * ```
117 *
118 * @resource
119 * @category DiscoveryEngine
120 */
121export const DataStoresSession = Resource<DataStoresSession>(
122 "GCP.DiscoveryEngine.DataStoresSession",
123);
124
125export class DataStoresSessionNotResolved extends Data.TaggedError(
126 "GCP.DiscoveryEngine.DataStoresSessionNotResolved",
127)<{
128 name: string;
129}> {}
130
131const resourceName = (dataStore: string, sessionId: string) =>
132 `${dataStore}/sessions/${sessionId}`;
133
134const ownershipLabels = (labels: Record<string, string>): string[] => [
135 `${alchemyLabelKeys.stack}=${labels[alchemyLabelKeys.stack]}`,
136 `${alchemyLabelKeys.stage}=${labels[alchemyLabelKeys.stage]}`,
137 `${alchemyLabelKeys.id}=${labels[alchemyLabelKeys.id]}`,
138];
139
140const userStringLabels = (labels: readonly string[] | undefined): string[] =>
141 [...(labels ?? [])].filter((label) => !label.startsWith("alchemy-"));
142
143/** Parse `alchemy-*=value` session labels back into a label map. */
144const alchemyLabelsOf = (labels: readonly string[] | undefined) =>
145 Object.fromEntries(
146 (labels ?? [])
147 .filter((label) => label.startsWith("alchemy-") && label.includes("="))
148 .map((label) => {
149 const at = label.indexOf("=");
150 return [label.slice(0, at), label.slice(at + 1)] as const;
151 }),
152 );
153
154const toAttrs = (
155 session: discoveryengine.GoogleCloudDiscoveryengineV1Session,
156 project: string,
157) => {
158 const name = session.name ?? "";
159 const parsed = parseResourceName(name, "sessions");
160 return {
161 name,
162 sessionId: parsed.id,
163 dataStore: parsed.dataStore,
164 project: parsed.project || project,
165 location: parsed.location,
166 displayName: session.displayName,
167 userPseudoId: session.userPseudoId,
168 state: session.state,
169 isPinned: session.isPinned === true,
170 labels: userStringLabels(session.labels),
171 startTime: session.startTime,
172 endTime: session.endTime,
173 };
174};
175
176const getByName = (name: string) =>
177 name.length === 0
178 ? Effect.succeed(undefined)
179 : discoveryengine
180 .getProjectsLocationsDataStoresSessions({ name })
181 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
182
183const listAtParent = (parent: string) =>
184 discoveryengine.listProjectsLocationsDataStoresSessions
185 .pages({ parent, pageSize: 100 })
186 .pipe(
187 Stream.flatMap((page) => Stream.fromIterable(page.sessions ?? [])),
188 Stream.filter((session) =>
189 (session.labels ?? []).some((label) => label.startsWith("alchemy-")),
190 ),
191 Stream.runCollect,
192 Effect.map((chunk) => Array.from(chunk)),
193 Effect.catchTag("NotFound", () => Effect.succeed([])),
194 );
195
196export const DataStoresSessionProvider = () =>
197 Provider.succeed(DataStoresSession, {
198 stables: [
199 "name",
200 "sessionId",
201 "dataStore",
202 "project",
203 "location",
204 "startTime",
205 ],
206
207 diff: Effect.fn(function* ({ news, olds, output }) {
208 if (!isResolved(news)) return undefined;
209 const previousParent = olds?.dataStore ?? output?.dataStore;
210 if (previousParent !== undefined && news.dataStore !== previousParent) {
211 return { action: "replace" as const, deleteFirst: false };
212 }
213 const previousId = olds?.sessionId ?? output?.sessionId;
214 if (
215 previousId !== undefined &&
216 news.sessionId !== undefined &&
217 news.sessionId !== previousId
218 ) {
219 return { action: "replace" as const, deleteFirst: true };
220 }
221 return undefined;
222 }),
223
224 read: Effect.fn(function* ({ id, olds, output }) {
225 const env = yield* GcpEnvironment.current;
226 const sessionId = yield* toPhysical(
227 id,
228 olds?.sessionId,
229 output?.sessionId,
230 sessionIdOf,
231 );
232 const parent = olds?.dataStore
233 ? expandDataStore(
234 olds.dataStore,
235 env.project,
236 output?.location ?? "global",
237 )
238 : undefined;
239 const name =
240 output?.name ?? (parent ? resourceName(parent, sessionId) : "");
241 const existing = yield* getByName(name);
242 if (existing === undefined) return undefined;
243 const attrs = toAttrs(existing, env.project);
244 return (yield* hasAlchemyLabels(id, alchemyLabelsOf(existing.labels)))
245 ? attrs
246 : Unowned(attrs);
247 }),
248
249 list: () =>
250 Effect.gen(function* () {
251 const env = yield* GcpEnvironment.current;
252 const stores = yield* listProjectDataStores(env.project);
253 const pages = yield* Effect.forEach(
254 stores,
255 (store) =>
256 store.name
257 ? listAtParent(store.name).pipe(
258 Effect.map((sessions) =>
259 sessions.map((session) => toAttrs(session, env.project)),
260 ),
261 )
262 : Effect.succeed([]),
263 { concurrency: 4 },
264 );
265 return pages.flat();
266 }),
267
268 reconcile: Effect.fn(function* ({ id, news, output }) {
269 const env = yield* GcpEnvironment.current;
270 const parent = expandDataStore(
271 news.dataStore,
272 env.project,
273 output?.location ?? "global",
274 );
275 const sessionId = yield* toPhysical(
276 id,
277 news.sessionId,
278 output?.sessionId,
279 sessionIdOf,
280 );
281 const name = resourceName(parent, sessionId);
282 const displayName = news.displayName;
283 const labels = yield* internalLabels(id);
284 const desiredLabels = [
285 ...ownershipLabels(labels),
286 ...userStringLabels(news.labels),
287 ];
288
289 let current = yield* getByName(output?.name ?? name);
290
291 if (current === undefined) {
292 const created = yield* discoveryengine
293 .createProjectsLocationsDataStoresSessions({
294 parent,
295 sessionId,
296 body: {
297 displayName,
298 userPseudoId: news.userPseudoId,
299 state: news.state,
300 isPinned: news.isPinned === true ? true : undefined,
301 labels: desiredLabels,
302 },
303 })
304 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
305 current = created ?? undefined;
306 }
307
308 if (current === undefined) {
309 return yield* new DataStoresSessionNotResolved({ name });
310 }
311
312 const displayChanged =
313 (current.displayName ?? "") !== (displayName ?? "");
314 const userChanged =
315 (current.userPseudoId ?? "") !== (news.userPseudoId ?? "");
316 const stateChanged =
317 news.state !== undefined && (current.state ?? "") !== news.state;
318 const pinChanged =
319 (current.isPinned === true) !== (news.isPinned === true);
320 const labelsChanged = !sameStringList(current.labels, desiredLabels);
321
322 if (
323 displayChanged ||
324 userChanged ||
325 stateChanged ||
326 pinChanged ||
327 labelsChanged
328 ) {
329 current =
330 yield* discoveryengine.patchProjectsLocationsDataStoresSessions({
331 name: current.name ?? name,
332 updateMask: [
333 displayChanged ? "display_name" : undefined,
334 userChanged ? "user_pseudo_id" : undefined,
335 stateChanged ? "state" : undefined,
336 pinChanged ? "is_pinned" : undefined,
337 labelsChanged ? "labels" : undefined,
338 ]
339 .filter((field): field is string => field !== undefined)
340 .join(","),
341 body: {
342 name: current.name ?? name,
343 displayName,
344 userPseudoId: news.userPseudoId,
345 state: news.state,
346 isPinned: news.isPinned === true,
347 labels: desiredLabels,
348 },
349 });
350 }
351
352 return toAttrs(current, env.project);
353 }),
354
355 delete: Effect.fn(function* ({ output }) {
356 const existing = yield* getByName(output.name);
357 if (existing === undefined) return;
358 yield* discoveryengine
359 .deleteProjectsLocationsDataStoresSessions({ name: output.name })
360 .pipe(Effect.catchTag("NotFound", () => Effect.void));
361 }),
362 });