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 desiredAccounts,
12 encodeOwnershipLine,
13 findHoldByName,
14 findOwnedHold,
15 getHold,
16 hasOwnershipMarker,
17 ignoreMissing,
18 jsonEqual,
19 listActiveMatters,
20 listHolds,
21 MAX_HOLD_NAME_LENGTH,
22 ownedByAlchemy,
23 ownershipLabels,
24 parseOwnership,
25 sameAccounts,
26 sameOrgUnit,
27 toGeneratedName,
28} from "./internal.ts";
29
30export type HeldAccount = {
31 /** Primary email. Takes precedence over `accountId` on write. */
32 email?: string;
33 /** Admin SDK account id. */
34 accountId?: string;
35};
36
37export type HeldOrgUnit = {
38 /** Admin SDK organizational unit id. */
39 orgUnitId?: string;
40};
41
42export type MattersHoldProps = {
43 /**
44 * Parent matter id. Immutable — changing it replaces the hold.
45 */
46 matterId: string;
47 /**
48 * Server-assigned hold id. Leave blank on create. Immutable —
49 * changing it replaces the hold.
50 */
51 holdId?: string;
52 /**
53 * Hold name (max 100 characters including Alchemy's ownership marker).
54 * Vault holds have no labels field, so ownership is stored in a
55 * `[alchemy …]` prefix and stripped from attributes. Immutable —
56 * changing it replaces the hold.
57 */
58 name?: string;
59 /**
60 * Workspace service covered by the hold. Immutable — changing it
61 * replaces the hold.
62 */
63 corpus: vault.HoldCorpusEnum | (string & {});
64 /**
65 * Individual accounts covered by the hold. Mutually exclusive with
66 * `orgUnit` — switching scope replaces the hold.
67 */
68 accounts?: HeldAccount[];
69 /**
70 * Organizational unit covered by the hold. Mutually exclusive with
71 * `accounts` — switching scope replaces the hold.
72 */
73 orgUnit?: HeldOrgUnit;
74 /**
75 * Service-specific query options. Must match `corpus`.
76 */
77 query?: vault.CorpusQuery;
78};
79
80export type MattersHold = Resource<
81 "GCP.Vault.MattersHold",
82 MattersHoldProps,
83 {
84 /** Server-assigned hold id. */
85 holdId: string;
86 /** Parent matter id. */
87 matterId: string;
88 /** Project id used when the hold was reconciled. */
89 project: string;
90 /** User-facing name with the Alchemy ownership prefix stripped. */
91 name: string | undefined;
92 /** Workspace service covered by the hold. */
93 corpus: string | undefined;
94 /** Individual accounts on the hold. */
95 accounts: HeldAccount[] | undefined;
96 /** Organizational unit on the hold. */
97 orgUnit: HeldOrgUnit | undefined;
98 /** Service-specific query options. */
99 query: vault.CorpusQuery | undefined;
100 /** RFC3339 last-update timestamp. */
101 updateTime: string | undefined;
102 },
103 never,
104 Providers
105>;
106
107/**
108 * A Google Vault hold on a matter.
109 *
110 * Vault holds have no labels field, so Alchemy stamps ownership into
111 * `name` for `list` / nuke. Parent matter, hold id, corpus, and name are
112 * identity. Query, accounts, and org unit update in place. Switching
113 * between accounts and an org unit replaces the hold.
114 *
115 * ### Creating a Hold
116 * **Example:** Mail hold on accounts
117 * ```typescript
118 * const hold = yield* GCP.Vault.MattersHold("Mail", {
119 * matterId: matter.matterId,
120 * corpus: "MAIL",
121 * accounts: [{ email: "user@example.com" }],
122 * query: { mailQuery: { terms: "subject:contract" } },
123 * });
124 * ```
125 *
126 * **Example:** Drive hold on an org unit
127 * ```typescript
128 * const hold = yield* GCP.Vault.MattersHold("Drive", {
129 * matterId: matter.matterId,
130 * name: "Drive OU",
131 * corpus: "DRIVE",
132 * orgUnit: { orgUnitId: "id:ou" },
133 * query: { driveQuery: { includeSharedDriveFiles: true } },
134 * });
135 * ```
136 *
137 * ### Updating a Hold
138 * **Example:** Narrow the mail query
139 * ```typescript
140 * // Same logical id, changed props: the engine updates it in place.
141 * const hold = yield* GCP.Vault.MattersHold("Mail", {
142 * matterId: matter.matterId,
143 * corpus: "MAIL",
144 * accounts: [{ email: "user@example.com" }],
145 * query: { mailQuery: { terms: "subject:contract 2026" } },
146 * });
147 * ```
148 *
149 * @resource
150 * @category Vault
151 */
152export const MattersHold = Resource<MattersHold>("GCP.Vault.MattersHold");
153
154export class MattersHoldNotResolved extends Data.TaggedError(
155 "GCP.Vault.MattersHoldNotResolved",
156)<{
157 matterId: string;
158 holdId: string;
159}> {}
160
161const accountsOf = (
162 accounts: vault.HeldAccountList | undefined,
163): HeldAccount[] | undefined => {
164 if (accounts === undefined) return undefined;
165 return accounts.map((account) => ({
166 email: account.email,
167 accountId: account.accountId,
168 }));
169};
170
171const orgUnitOf = (
172 orgUnit: vault.HeldOrgUnit | undefined,
173): HeldOrgUnit | undefined => {
174 if (orgUnit === undefined) return undefined;
175 return { orgUnitId: orgUnit.orgUnitId };
176};
177
178const toAttrs = (hold: vault.Hold, matterId: string, project: string) => ({
179 holdId: hold.holdId ?? "",
180 matterId: matterId,
181 project,
182 name: parseOwnership(hold.name).text,
183 corpus: hold.corpus,
184 accounts: accountsOf(hold.accounts),
185 orgUnit: orgUnitOf(hold.orgUnit),
186 query: hold.query,
187 updateTime: hold.updateTime,
188});
189
190export const MattersHoldProvider = () =>
191 Provider.succeed(MattersHold, {
192 stables: ["holdId", "matterId", "project"],
193
194 diff: Effect.fn(function* ({ news, olds, output }) {
195 if (!isResolved(news)) return undefined;
196 const previousMatter = olds?.matterId ?? output?.matterId;
197 if (previousMatter !== undefined && news.matterId !== previousMatter) {
198 return { action: "replace" as const, deleteFirst: true };
199 }
200 const previousId = olds?.holdId ?? output?.holdId;
201 if (
202 previousId !== undefined &&
203 news.holdId !== undefined &&
204 news.holdId !== previousId
205 ) {
206 return { action: "replace" as const, deleteFirst: true };
207 }
208 const previousCorpus = olds?.corpus ?? output?.corpus;
209 if (previousCorpus !== undefined && news.corpus !== previousCorpus) {
210 return { action: "replace" as const, deleteFirst: true };
211 }
212 const previousName = olds?.name ?? output?.name;
213 if (
214 news.name !== undefined &&
215 previousName !== undefined &&
216 news.name !== previousName
217 ) {
218 return { action: "replace" as const, deleteFirst: true };
219 }
220 const previousOrg = olds?.orgUnit ?? output?.orgUnit;
221 const previousAccounts = olds?.accounts ?? output?.accounts;
222 if (
223 previousOrg?.orgUnitId !== undefined &&
224 news.accounts !== undefined &&
225 news.accounts.length > 0
226 ) {
227 return { action: "replace" as const, deleteFirst: true };
228 }
229 if (
230 previousAccounts !== undefined &&
231 previousAccounts.length > 0 &&
232 news.orgUnit?.orgUnitId !== undefined
233 ) {
234 return { action: "replace" as const, deleteFirst: true };
235 }
236 return undefined;
237 }),
238
239 read: Effect.fn(function* ({ id, olds, output }) {
240 const env = yield* GcpEnvironment.current;
241 const matterId = olds?.matterId ?? output?.matterId ?? "";
242 const holdId = olds?.holdId ?? output?.holdId ?? "";
243 let existing = yield* getHold(matterId, holdId);
244 let parentId = matterId;
245 if (existing === undefined) {
246 const ownership = yield* ownershipLabels(id);
247 const name = encodeOwnershipLine(
248 ownership,
249 olds?.name ?? output?.name,
250 MAX_HOLD_NAME_LENGTH,
251 );
252 existing = yield* findHoldByName(matterId, name);
253 }
254 if (existing === undefined) {
255 const owned = yield* findOwnedHold(id, matterId);
256 if (owned !== undefined) {
257 existing = owned.hold;
258 parentId = owned.matterId;
259 }
260 }
261 if (existing === undefined) return undefined;
262 const attrs = toAttrs(
263 existing,
264 parentId || (existing.holdId ?? ""),
265 env.project,
266 );
267 const resolvedMatter = parentId.length > 0 ? parentId : attrs.matterId;
268 return (yield* ownedByAlchemy(id, existing.name))
269 ? { ...attrs, matterId: resolvedMatter }
270 : Unowned({ ...attrs, matterId: resolvedMatter });
271 }),
272
273 list: () =>
274 Effect.gen(function* () {
275 const env = yield* GcpEnvironment.current;
276 const matters = yield* listActiveMatters();
277 const pages = yield* Effect.forEach(
278 matters,
279 (matter) =>
280 listHolds(matter.matterId ?? "").pipe(
281 Effect.map((holds) =>
282 holds
283 .filter((hold) => hasOwnershipMarker(hold.name))
284 .map((hold) =>
285 toAttrs(hold, matter.matterId ?? "", env.project),
286 ),
287 ),
288 ),
289 { concurrency: 4 },
290 );
291 return pages.flat();
292 }),
293
294 reconcile: Effect.fn(function* ({ id, news, output }) {
295 const env = yield* GcpEnvironment.current;
296 const matterId = news.matterId;
297 const ownership = yield* ownershipLabels(id);
298 const displayName = yield* toGeneratedName(
299 id,
300 news.name,
301 output?.name,
302 40,
303 );
304 const name = encodeOwnershipLine(
305 ownership,
306 displayName,
307 MAX_HOLD_NAME_LENGTH,
308 );
309 const accounts = desiredAccounts(news.accounts);
310 const desired: vault.Hold = {
311 name,
312 corpus: news.corpus,
313 accounts,
314 orgUnit: news.orgUnit,
315 query: news.query,
316 };
317
318 let current = yield* getHold(
319 matterId,
320 news.holdId ?? output?.holdId ?? "",
321 );
322 if (current === undefined) {
323 current = yield* findHoldByName(matterId, name);
324 }
325 if (current === undefined) {
326 const owned = yield* findOwnedHold(id, matterId);
327 current = owned?.hold;
328 }
329
330 if (current === undefined) {
331 const created = yield* vault
332 .createMattersHolds({
333 matterId,
334 body: desired,
335 })
336 .pipe(
337 Effect.catchTag("Conflict", () => findHoldByName(matterId, name)),
338 );
339 current = created ?? undefined;
340 }
341
342 if (current === undefined) {
343 return yield* new MattersHoldNotResolved({
344 matterId,
345 holdId: news.holdId ?? output?.holdId ?? name,
346 });
347 }
348
349 const holdId = current.holdId ?? news.holdId ?? output?.holdId ?? "";
350 const queryChanged =
351 news.query !== undefined && !jsonEqual(current.query, news.query);
352 const accountsChanged =
353 news.accounts !== undefined &&
354 !sameAccounts(current.accounts, news.accounts);
355 const orgUnitChanged =
356 news.orgUnit !== undefined &&
357 !sameOrgUnit(current.orgUnit, news.orgUnit);
358
359 if (queryChanged || accountsChanged || orgUnitChanged) {
360 current = yield* vault.updateMattersHolds({
361 matterId,
362 holdId,
363 body: {
364 query: news.query ?? current.query,
365 accounts: accounts ?? current.accounts,
366 orgUnit: news.orgUnit ?? current.orgUnit,
367 },
368 });
369 }
370
371 return toAttrs(current, matterId, env.project);
372 }),
373
374 delete: Effect.fn(function* ({ output }) {
375 if (output.matterId.length === 0 || output.holdId.length === 0) return;
376 yield* ignoreMissing(
377 vault.deleteMattersHolds({
378 matterId: output.matterId,
379 holdId: output.holdId,
380 }),
381 );
382 }),
383 });