1import * as vault from "@distilled.cloud/gcp/vault_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 encodeOwnershipLine,
12 findOwnedSavedQuery,
13 findSavedQueryByName,
14 getSavedQuery,
15 hasOwnershipMarker,
16 ignoreMissing,
17 jsonEqual,
18 listActiveMatters,
19 listSavedQueries,
20 MAX_SAVED_QUERY_NAME_LENGTH,
21 ownedByAlchemy,
22 ownershipLabels,
23 parseOwnership,
24 toGeneratedName,
25} from "./internal.ts";
26
27export type MattersSavedQueryProps = {
28 /**
29 * Parent matter id. Immutable — changing it replaces the saved query.
30 */
31 matterId: string;
32 /**
33 * Server-assigned saved query id. Leave blank on create. Immutable —
34 * changing it replaces the saved query.
35 */
36 savedQueryId?: string;
37 /**
38 * Display name (max 100 characters including Alchemy's ownership
39 * marker). Vault saved queries have no labels field, so ownership is
40 * stored in a `[alchemy …]` prefix and stripped from attributes. Vault
41 * has no saved-query update API — changing the name replaces the
42 * query.
43 */
44 displayName?: string;
45 /**
46 * Search parameters. Required on create. Vault has no saved-query
47 * update API — changing the query replaces the saved query.
48 */
49 query: vault.Query;
50};
51
52export type MattersSavedQuery = Resource<
53 "GCP.Vault.MattersSavedQuery",
54 MattersSavedQueryProps,
55 {
56 /** Server-assigned saved query id. */
57 savedQueryId: string;
58 /** Parent matter id. */
59 matterId: string;
60 /** Project id used when the saved query was reconciled. */
61 project: string;
62 /** User-facing name with the Alchemy ownership prefix stripped. */
63 displayName: string | undefined;
64 /** Search parameters. */
65 query: vault.Query | undefined;
66 /** RFC3339 create timestamp. */
67 createTime: string | undefined;
68 },
69 never,
70 Providers
71>;
72
73/**
74 * A Google Vault saved query on a matter.
75 *
76 * Vault saved queries have no labels field, so Alchemy stamps ownership
77 * into `displayName` for `list` / nuke. There is no update API — changing
78 * the query or display name replaces the saved query (delete-first).
79 *
80 * ### Creating a Saved Query
81 * **Example:** Mail search
82 * ```typescript
83 * const saved = yield* GCP.Vault.MattersSavedQuery("Contracts", {
84 * matterId: matter.matterId,
85 * query: {
86 * corpus: "MAIL",
87 * dataScope: "ALL_DATA",
88 * searchMethod: "ACCOUNT",
89 * accountInfo: { emails: ["user@example.com"] },
90 * terms: "subject:contract",
91 * },
92 * });
93 * ```
94 *
95 * **Example:** Named Drive search
96 * ```typescript
97 * const saved = yield* GCP.Vault.MattersSavedQuery("Drive", {
98 * matterId: matter.matterId,
99 * displayName: "shared-drives",
100 * query: {
101 * corpus: "DRIVE",
102 * dataScope: "ALL_DATA",
103 * searchMethod: "ENTIRE_ORG",
104 * },
105 * });
106 * ```
107 *
108 * @resource
109 * @category Vault
110 */
111export const MattersSavedQuery = Resource<MattersSavedQuery>(
112 "GCP.Vault.MattersSavedQuery",
113);
114
115export class MattersSavedQueryNotResolved extends Data.TaggedError(
116 "GCP.Vault.MattersSavedQueryNotResolved",
117)<{
118 matterId: string;
119 savedQueryId: string;
120}> {}
121
122const toAttrs = (item: vault.SavedQuery, project: string) => ({
123 savedQueryId: item.savedQueryId ?? "",
124 matterId: item.matterId ?? "",
125 project,
126 displayName: parseOwnership(item.displayName).text,
127 query: item.query,
128 createTime: item.createTime,
129});
130
131export const MattersSavedQueryProvider = () =>
132 Provider.succeed(MattersSavedQuery, {
133 stables: ["savedQueryId", "matterId", "project", "createTime"],
134
135 diff: Effect.fn(function* ({ news, olds, output }) {
136 if (!isResolved(news)) return undefined;
137 const previousMatter = olds?.matterId ?? output?.matterId;
138 if (previousMatter !== undefined && news.matterId !== previousMatter) {
139 return { action: "replace" as const, deleteFirst: true };
140 }
141 const previousId = olds?.savedQueryId ?? output?.savedQueryId;
142 if (
143 previousId !== undefined &&
144 news.savedQueryId !== undefined &&
145 news.savedQueryId !== previousId
146 ) {
147 return { action: "replace" as const, deleteFirst: true };
148 }
149 const previousName = olds?.displayName ?? output?.displayName;
150 if (
151 news.displayName !== undefined &&
152 previousName !== undefined &&
153 news.displayName !== previousName
154 ) {
155 return { action: "replace" as const, deleteFirst: true };
156 }
157 const previousQuery = olds?.query ?? output?.query;
158 if (
159 previousQuery !== undefined &&
160 !jsonEqual(previousQuery, news.query)
161 ) {
162 return { action: "replace" as const, deleteFirst: true };
163 }
164 return undefined;
165 }),
166
167 read: Effect.fn(function* ({ id, olds, output }) {
168 const env = yield* GcpEnvironment.current;
169 const matterId = olds?.matterId ?? output?.matterId ?? "";
170 const savedQueryId = olds?.savedQueryId ?? output?.savedQueryId ?? "";
171 let existing = yield* getSavedQuery(matterId, savedQueryId);
172 if (existing === undefined) {
173 const ownership = yield* ownershipLabels(id);
174 const displayName = encodeOwnershipLine(
175 ownership,
176 olds?.displayName ?? output?.displayName,
177 MAX_SAVED_QUERY_NAME_LENGTH,
178 );
179 existing = yield* findSavedQueryByName(matterId, displayName);
180 }
181 if (existing === undefined) {
182 existing = yield* findOwnedSavedQuery(id, matterId);
183 }
184 if (existing === undefined) return undefined;
185 const attrs = toAttrs(existing, env.project);
186 return (yield* ownedByAlchemy(id, existing.displayName))
187 ? attrs
188 : Unowned(attrs);
189 }),
190
191 list: () =>
192 Effect.gen(function* () {
193 const env = yield* GcpEnvironment.current;
194 const matters = yield* listActiveMatters();
195 const pages = yield* Effect.forEach(
196 matters,
197 (matter) =>
198 listSavedQueries(matter.matterId ?? "").pipe(
199 Effect.map((items) =>
200 items
201 .filter((item) => hasOwnershipMarker(item.displayName))
202 .map((item) => toAttrs(item, env.project)),
203 ),
204 ),
205 { concurrency: 4 },
206 );
207 return pages.flat();
208 }),
209
210 reconcile: Effect.fn(function* ({ id, news, output }) {
211 const env = yield* GcpEnvironment.current;
212 const matterId = news.matterId;
213 const ownership = yield* ownershipLabels(id);
214 const generated = yield* toGeneratedName(
215 id,
216 news.displayName,
217 output?.displayName,
218 40,
219 );
220 const displayName = encodeOwnershipLine(
221 ownership,
222 generated,
223 MAX_SAVED_QUERY_NAME_LENGTH,
224 );
225 const desired: vault.SavedQuery = {
226 displayName,
227 query: news.query,
228 };
229
230 let current = yield* getSavedQuery(
231 matterId,
232 news.savedQueryId ?? output?.savedQueryId ?? "",
233 );
234 if (current === undefined) {
235 current = yield* findSavedQueryByName(matterId, displayName);
236 }
237 if (current === undefined) {
238 current = yield* findOwnedSavedQuery(id, matterId);
239 }
240
241 if (current === undefined) {
242 const created = yield* vault
243 .createMattersSavedQueries({
244 matterId,
245 body: desired,
246 })
247 .pipe(
248 Effect.catchTag("Conflict", () =>
249 findSavedQueryByName(matterId, displayName),
250 ),
251 );
252 current = created ?? undefined;
253 }
254
255 if (current === undefined) {
256 return yield* new MattersSavedQueryNotResolved({
257 matterId,
258 savedQueryId:
259 news.savedQueryId ?? output?.savedQueryId ?? displayName,
260 });
261 }
262
263 return toAttrs(current, env.project);
264 }),
265
266 delete: Effect.fn(function* ({ output }) {
267 if (output.matterId.length === 0 || output.savedQueryId.length === 0) {
268 return;
269 }
270 yield* ignoreMissing(
271 vault.deleteMattersSavedQueries({
272 matterId: output.matterId,
273 savedQueryId: output.savedQueryId,
274 }),
275 );
276 }),
277 });