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 closeThenDeleteMatter,
12 encodeOwnership,
13 findOwnedMatter,
14 getMatter,
15 hasOwnershipMarker,
16 listActiveMatters,
17 MAX_MATTER_NAME_LENGTH,
18 ownedByAlchemy,
19 ownershipLabels,
20 parseOwnership,
21 sameText,
22 toGeneratedName,
23} from "./internal.ts";
24
25export type MatterPermission = {
26 /** Collaborator role (`COLLABORATOR` or `OWNER`). */
27 role?: vault.MatterPermissionRoleEnum | (string & {});
28 /** Admin SDK account id. */
29 accountId?: string;
30};
31
32export type MatterProps = {
33 /**
34 * Server-assigned matter id. Leave blank on create. Immutable —
35 * changing it replaces the matter.
36 */
37 matterId?: string;
38 /**
39 * Display name (max 100 characters). If omitted, a unique name is
40 * generated from the stack, stage, and logical id.
41 */
42 name?: string;
43 /**
44 * Optional description. Vault matters have no labels field, so Alchemy
45 * ownership is stored in a `[alchemy …]` prefix and stripped from
46 * attributes.
47 */
48 description?: string;
49 /**
50 * Requested data region. Immutable — changing it replaces the matter.
51 */
52 matterRegion?: vault.MatterMatterRegionEnum | (string & {});
53 /**
54 * Desired matter state. Reconcile keeps the matter `OPEN` unless this
55 * is set to `CLOSED`.
56 */
57 state?: vault.MatterStateEnum | (string & {});
58};
59
60export type Matter = Resource<
61 "GCP.Vault.Matter",
62 MatterProps,
63 {
64 /** Server-assigned matter id. */
65 matterId: string;
66 /** Project id used when the matter was reconciled. */
67 project: string;
68 /** Display name. */
69 name: string;
70 /** User description with the Alchemy ownership prefix stripped. */
71 description: string | undefined;
72 /** Data region. */
73 matterRegion: string | undefined;
74 /** Matter state (`OPEN`, `CLOSED`, or `DELETED`). */
75 state: string | undefined;
76 /** Collaborators. */
77 matterPermissions: MatterPermission[] | undefined;
78 },
79 never,
80 Providers
81>;
82
83/**
84 * A Google Vault matter (eDiscovery case).
85 *
86 * Vault matters have no labels field, so Alchemy stamps ownership into
87 * `description` for `list` / nuke. The server-assigned id and data region
88 * are identity — changing either replaces the matter. Name and description
89 * update in place. Delete closes the matter first, then soft-deletes it.
90 *
91 * ### Creating a Matter
92 * **Example:** Generated name
93 * ```typescript
94 * const matter = yield* GCP.Vault.Matter("Case", {});
95 * ```
96 *
97 * **Example:** Explicit name and description
98 * ```typescript
99 * const matter = yield* GCP.Vault.Matter("Case", {
100 * name: "Acme v Contoso",
101 * description: "litigation hold",
102 * matterRegion: "US",
103 * });
104 * ```
105 *
106 * ### Updating a Matter
107 * **Example:** Rename
108 * ```typescript
109 * // Same logical id, changed props: the engine updates it in place.
110 * const matter = yield* GCP.Vault.Matter("Case", {
111 * name: "Acme v Contoso 2026",
112 * description: "litigation hold",
113 * });
114 * ```
115 *
116 * @resource
117 * @category Vault
118 */
119export const Matter = Resource<Matter>("GCP.Vault.Matter");
120
121export class MatterNotResolved extends Data.TaggedError(
122 "GCP.Vault.MatterNotResolved",
123)<{
124 matterId: string;
125}> {}
126
127const permissionsOf = (
128 permissions: vault.MatterPermissionList | undefined,
129): MatterPermission[] | undefined => {
130 if (permissions === undefined) return undefined;
131 return permissions.map((permission) => ({
132 role: permission.role,
133 accountId: permission.accountId,
134 }));
135};
136
137const toAttrs = (matter: vault.Matter, project: string) => ({
138 matterId: matter.matterId ?? "",
139 project,
140 name: matter.name ?? "",
141 description: parseOwnership(matter.description).text,
142 matterRegion: matter.matterRegion,
143 state: matter.state,
144 matterPermissions: permissionsOf(matter.matterPermissions),
145});
146
147const ensureOpen = (matter: vault.Matter) =>
148 Effect.gen(function* () {
149 const matterId = matter.matterId ?? "";
150 if (matterId.length === 0) return matter;
151 if (matter.state === "DELETED") {
152 return yield* vault.undeleteMatters({ matterId, body: {} });
153 }
154 if (matter.state === "CLOSED") {
155 const reopened = yield* vault.reopenMatters({ matterId, body: {} });
156 return reopened.matter ?? matter;
157 }
158 return matter;
159 });
160
161export const MatterProvider = () =>
162 Provider.succeed(Matter, {
163 stables: ["matterId", "project"],
164
165 diff: Effect.fn(function* ({ news, olds, output }) {
166 if (!isResolved(news)) return undefined;
167 const previousId = olds?.matterId ?? output?.matterId;
168 if (
169 previousId !== undefined &&
170 news.matterId !== undefined &&
171 news.matterId !== previousId
172 ) {
173 return { action: "replace" as const, deleteFirst: false };
174 }
175 const previousRegion = olds?.matterRegion ?? output?.matterRegion;
176 if (
177 previousRegion !== undefined &&
178 news.matterRegion !== undefined &&
179 news.matterRegion !== previousRegion
180 ) {
181 return { action: "replace" as const, deleteFirst: false };
182 }
183 return undefined;
184 }),
185
186 read: Effect.fn(function* ({ id, olds, output }) {
187 const env = yield* GcpEnvironment.current;
188 const matterId = olds?.matterId ?? output?.matterId ?? "";
189 let existing = yield* getMatter(matterId);
190 if (existing === undefined || existing.state === "DELETED") {
191 existing = yield* findOwnedMatter(id);
192 }
193 if (existing === undefined || existing.state === "DELETED") {
194 return undefined;
195 }
196 const attrs = toAttrs(existing, env.project);
197 return (yield* ownedByAlchemy(id, existing.description))
198 ? attrs
199 : Unowned(attrs);
200 }),
201
202 list: () =>
203 Effect.gen(function* () {
204 const env = yield* GcpEnvironment.current;
205 const matters = yield* listActiveMatters();
206 return matters
207 .filter((matter) => hasOwnershipMarker(matter.description))
208 .map((matter) => toAttrs(matter, env.project));
209 }),
210
211 reconcile: Effect.fn(function* ({ id, news, output }) {
212 const env = yield* GcpEnvironment.current;
213 const ownership = yield* ownershipLabels(id);
214 const name = yield* toGeneratedName(
215 id,
216 news.name,
217 output?.name,
218 MAX_MATTER_NAME_LENGTH,
219 );
220 const description = encodeOwnership(ownership, news.description);
221 const desired: vault.Matter = {
222 name,
223 description,
224 matterRegion: news.matterRegion,
225 };
226
227 let current = yield* getMatter(news.matterId ?? output?.matterId ?? "");
228 if (current === undefined || current.state === "DELETED") {
229 const owned = yield* findOwnedMatter(id);
230 if (owned !== undefined) current = owned;
231 }
232
233 if (current === undefined || current.state === "DELETED") {
234 if (current?.state === "DELETED" && current.matterId) {
235 current = yield* vault.undeleteMatters({
236 matterId: current.matterId,
237 body: {},
238 });
239 } else {
240 const created = yield* vault
241 .createMatters({ body: desired })
242 .pipe(Effect.catchTag("Conflict", () => findOwnedMatter(id)));
243 current = created ?? undefined;
244 }
245 }
246
247 if (current === undefined) {
248 return yield* new MatterNotResolved({
249 matterId: news.matterId ?? output?.matterId ?? name,
250 });
251 }
252
253 if (news.state === "CLOSED") {
254 if (current.state === "DELETED") {
255 current = yield* vault.undeleteMatters({
256 matterId: current.matterId ?? "",
257 body: {},
258 });
259 }
260 if (current.state === "OPEN") {
261 const closed = yield* vault.closeMatters({
262 matterId: current.matterId ?? "",
263 body: {},
264 });
265 current = closed.matter ?? current;
266 }
267 } else {
268 current = yield* ensureOpen(current);
269 }
270
271 const matterId = current.matterId ?? "";
272 const nameChanged = !sameText(current.name, name);
273 const descriptionChanged = !sameText(current.description, description);
274 if (nameChanged || descriptionChanged) {
275 current = yield* vault.updateMatters({
276 matterId,
277 body: { name, description },
278 });
279 }
280
281 return toAttrs(current, env.project);
282 }),
283
284 delete: Effect.fn(function* ({ output }) {
285 yield* closeThenDeleteMatter(output.matterId);
286 }),
287 });