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