1import * as vault from "@distilled.cloud/gcp/vault_v1";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { createPhysicalName } from "../../PhysicalName.ts";
5import {
6 alchemyLabelKeys,
7 createInternalLabels,
8 hasAlchemyLabels,
9} from "../Labels.ts";
10
11export const MAX_MATTER_NAME_LENGTH = 100;
12export const MAX_HOLD_NAME_LENGTH = 100;
13export const MAX_EXPORT_NAME_LENGTH = 100;
14export const MAX_SAVED_QUERY_NAME_LENGTH = 100;
15
16const markerOf = (
17 _labels: Record<string, string>,
18 stack: string,
19 stage: string,
20 id: string,
21) =>
22 `[alchemy ${alchemyLabelKeys.stack}=${stack} ${alchemyLabelKeys.stage}=${stage} ${alchemyLabelKeys.id}=${id}]`;
23
24const fitMarker = (labels: Record<string, string>, maxLength: number) => {
25 let stack = labels[alchemyLabelKeys.stack] ?? "x";
26 let stage = labels[alchemyLabelKeys.stage] ?? "x";
27 let id = labels[alchemyLabelKeys.id] ?? "x";
28 let marker = markerOf(labels, stack, stage, id);
29 while (
30 marker.length > maxLength &&
31 (stack.length > 1 || stage.length > 1 || id.length > 1)
32 ) {
33 if (stack.length >= stage.length && stack.length >= id.length) {
34 stack = stack.slice(0, -1);
35 } else if (stage.length >= id.length) {
36 stage = stage.slice(0, -1);
37 } else {
38 id = id.slice(0, -1);
39 }
40 marker = markerOf(labels, stack, stage, id);
41 }
42 return marker.slice(0, maxLength);
43};
44
45export const encodeOwnership = (
46 labels: Record<string, string>,
47 text: string | undefined,
48): string => {
49 const marker = fitMarker(labels, 8000);
50 const trimmed = text?.trim();
51 return trimmed && trimmed.length > 0 ? `${marker}\n${trimmed}` : marker;
52};
53
54export const encodeOwnershipLine = (
55 labels: Record<string, string>,
56 text: string | undefined,
57 maxLength = MAX_HOLD_NAME_LENGTH,
58): string => {
59 const trimmed = text?.replace(/[\r\n]+/g, " ").trim();
60 if (!trimmed) return fitMarker(labels, maxLength);
61 const minMarker = 24;
62 const reserved = Math.min(
63 trimmed.length + 1,
64 Math.max(0, maxLength - minMarker),
65 );
66 const marker = fitMarker(labels, maxLength - reserved);
67 return `${marker} ${trimmed}`.slice(0, maxLength);
68};
69
70export const parseOwnership = (
71 text: string | undefined,
72): {
73 labels: Record<string, string>;
74 text: string | undefined;
75} => {
76 if (!text?.startsWith("[alchemy ")) {
77 return { labels: {}, text };
78 }
79 const end = text.indexOf("]");
80 if (end < 0) return { labels: {}, text };
81 const labels: Record<string, string> = {};
82 for (const part of text.slice("[alchemy ".length, end).split(/\s+/)) {
83 const eq = part.indexOf("=");
84 if (eq > 0) {
85 labels[part.slice(0, eq)] = part.slice(eq + 1);
86 }
87 }
88 const rest = text.slice(end + 1).replace(/^[\s\n]+/, "");
89 return { labels, text: rest.length > 0 ? rest : undefined };
90};
91
92export const hasOwnershipMarker = (text: string | undefined) =>
93 Object.keys(parseOwnership(text).labels).some((key) =>
94 key.startsWith("alchemy-"),
95 );
96
97const prefixMatch = (expected: string, observed: string) =>
98 expected === observed ||
99 expected.startsWith(observed) ||
100 observed.startsWith(expected);
101
102export const ownedByAlchemy = (id: string, text: string | undefined) =>
103 Effect.gen(function* () {
104 const expected = yield* createInternalLabels(id);
105 const { labels } = parseOwnership(text);
106 if (!hasOwnershipMarker(text)) return false;
107 const exact = yield* hasAlchemyLabels(id, labels);
108 if (exact) return true;
109 return (
110 prefixMatch(
111 expected[alchemyLabelKeys.stack] ?? "",
112 labels[alchemyLabelKeys.stack] ?? "",
113 ) &&
114 prefixMatch(
115 expected[alchemyLabelKeys.stage] ?? "",
116 labels[alchemyLabelKeys.stage] ?? "",
117 ) &&
118 prefixMatch(
119 expected[alchemyLabelKeys.id] ?? "",
120 labels[alchemyLabelKeys.id] ?? "",
121 )
122 );
123 });
124
125export const ownershipLabels = (id: string) => createInternalLabels(id);
126
127export const sameText = (left: string | undefined, right: string | undefined) =>
128 (left ?? "") === (right ?? "");
129
130export const jsonEqual = (left: unknown, right: unknown) =>
131 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
132
133export const toGeneratedName = (
134 id: string,
135 requested: string | undefined,
136 existing: string | undefined,
137 maxLength = 40,
138) =>
139 Effect.gen(function* () {
140 if (requested !== undefined && requested.length > 0) {
141 return requested.slice(0, maxLength);
142 }
143 if (existing !== undefined && existing.length > 0) {
144 return existing.slice(0, maxLength);
145 }
146 const generated = yield* createPhysicalName({
147 id,
148 maxLength,
149 lowercase: true,
150 });
151 const next = /^[a-z]/.test(generated)
152 ? generated
153 : `v${generated}`.slice(0, maxLength);
154 return next.length >= 4 ? next : `${next}xxxx`.slice(0, maxLength);
155 });
156
157const emptyList = <A>() => Effect.succeed([] as A[]);
158
159export const catchMissing = <A, E extends { readonly _tag: string }, R>(
160 effect: Effect.Effect<A, E, R>,
161) =>
162 effect.pipe(
163 Effect.catchIf(
164 (error): error is E & { readonly _tag: "NotFound" } =>
165 error._tag === "NotFound",
166 () => Effect.succeed(undefined),
167 ),
168 );
169
170export const ignoreMissing = <E extends { readonly _tag: string }, R>(
171 effect: Effect.Effect<unknown, E, R>,
172) =>
173 effect.pipe(
174 Effect.catchIf(
175 (error): error is E & { readonly _tag: "NotFound" } =>
176 error._tag === "NotFound",
177 () => Effect.void,
178 ),
179 );
180
181export const getMatter = (matterId: string) =>
182 matterId.length === 0
183 ? Effect.succeed(undefined)
184 : catchMissing(vault.getMatters({ matterId, view: "FULL" }));
185
186export const listMatters = () =>
187 vault.listMatters.pages({ pageSize: 100 }).pipe(
188 Stream.flatMap((page) => Stream.fromIterable(page.matters ?? [])),
189 Stream.runCollect,
190 Effect.map((chunk) => Array.from(chunk)),
191 Effect.catchTag("NotFound", () => emptyList<vault.Matter>()),
192 );
193
194export const listActiveMatters = () =>
195 listMatters().pipe(
196 Effect.map((matters) =>
197 matters.filter(
198 (matter) =>
199 matter.state !== "DELETED" && (matter.matterId ?? "").length > 0,
200 ),
201 ),
202 );
203
204export const findOwnedMatter = (id: string) =>
205 Effect.gen(function* () {
206 const matters = yield* listMatters();
207 for (const matter of matters) {
208 if (yield* ownedByAlchemy(id, matter.description)) {
209 return matter;
210 }
211 }
212 return undefined;
213 });
214
215export const closeThenDeleteMatter = (matterId: string) =>
216 Effect.gen(function* () {
217 if (matterId.length === 0) return;
218 const current = yield* getMatter(matterId);
219 if (current === undefined || current.state === "DELETED") return;
220 if (current.state === "OPEN") {
221 yield* vault.closeMatters({ matterId, body: {} }).pipe(
222 Effect.catchTag("NotFound", () => Effect.void),
223 Effect.catchTag("BadRequest", () => Effect.void),
224 Effect.catchTag("Conflict", () => Effect.void),
225 );
226 }
227 yield* vault.deleteMatters({ matterId }).pipe(
228 Effect.catchTag("NotFound", () => Effect.void),
229 Effect.catchTag("BadRequest", () => Effect.void),
230 Effect.catchTag("Conflict", () => Effect.void),
231 );
232 });
233
234export const getHold = (matterId: string, holdId: string) =>
235 matterId.length === 0 || holdId.length === 0
236 ? Effect.succeed(undefined)
237 : catchMissing(
238 vault.getMattersHolds({
239 matterId,
240 holdId,
241 view: "FULL_HOLD",
242 }),
243 );
244
245export const listHolds = (matterId: string) =>
246 matterId.length === 0
247 ? emptyList<vault.Hold>()
248 : vault.listMattersHolds
249 .pages({ matterId, pageSize: 100, view: "FULL_HOLD" })
250 .pipe(
251 Stream.flatMap((page) => Stream.fromIterable(page.holds ?? [])),
252 Stream.runCollect,
253 Effect.map((chunk) => Array.from(chunk)),
254 Effect.catchTag("NotFound", () => emptyList<vault.Hold>()),
255 );
256
257export const findHoldByName = (matterId: string, name: string) =>
258 listHolds(matterId).pipe(
259 Effect.map((holds) => holds.find((hold) => hold.name === name)),
260 );
261
262export const findOwnedHold = (id: string, matterId?: string) =>
263 Effect.gen(function* () {
264 const parents =
265 matterId !== undefined && matterId.length > 0
266 ? [{ matterId }]
267 : yield* listActiveMatters();
268 for (const parent of parents) {
269 const parentId = parent.matterId ?? "";
270 const holds = yield* listHolds(parentId);
271 for (const hold of holds) {
272 if (yield* ownedByAlchemy(id, hold.name)) {
273 return { hold, matterId: parentId };
274 }
275 }
276 }
277 return undefined;
278 });
279
280export const getExport = (matterId: string, exportId: string) =>
281 matterId.length === 0 || exportId.length === 0
282 ? Effect.succeed(undefined)
283 : catchMissing(vault.getMattersExports({ matterId, exportId }));
284
285export const listExports = (matterId: string) =>
286 matterId.length === 0
287 ? emptyList<vault.Export>()
288 : vault.listMattersExports.pages({ matterId, pageSize: 100 }).pipe(
289 Stream.flatMap((page) => Stream.fromIterable(page.exports ?? [])),
290 Stream.runCollect,
291 Effect.map((chunk) => Array.from(chunk)),
292 Effect.catchTag("NotFound", () => emptyList<vault.Export>()),
293 );
294
295export const findExportByName = (matterId: string, name: string) =>
296 listExports(matterId).pipe(
297 Effect.map((items) => items.find((item) => item.name === name)),
298 );
299
300export const findOwnedExport = (id: string, matterId?: string) =>
301 Effect.gen(function* () {
302 const parents =
303 matterId !== undefined && matterId.length > 0
304 ? [{ matterId }]
305 : yield* listActiveMatters();
306 for (const parent of parents) {
307 const parentId = parent.matterId ?? "";
308 const items = yield* listExports(parentId);
309 for (const item of items) {
310 if (yield* ownedByAlchemy(id, item.name)) {
311 return item;
312 }
313 }
314 }
315 return undefined;
316 });
317
318export const getSavedQuery = (matterId: string, savedQueryId: string) =>
319 matterId.length === 0 || savedQueryId.length === 0
320 ? Effect.succeed(undefined)
321 : catchMissing(vault.getMattersSavedQueries({ matterId, savedQueryId }));
322
323export const listSavedQueries = (matterId: string) =>
324 matterId.length === 0
325 ? emptyList<vault.SavedQuery>()
326 : vault.listMattersSavedQueries.pages({ matterId, pageSize: 100 }).pipe(
327 Stream.flatMap((page) => Stream.fromIterable(page.savedQueries ?? [])),
328 Stream.runCollect,
329 Effect.map((chunk) => Array.from(chunk)),
330 Effect.catchTag("NotFound", () => emptyList<vault.SavedQuery>()),
331 );
332
333export const findSavedQueryByName = (matterId: string, displayName: string) =>
334 listSavedQueries(matterId).pipe(
335 Effect.map((items) =>
336 items.find((item) => item.displayName === displayName),
337 ),
338 );
339
340export const findOwnedSavedQuery = (id: string, matterId?: string) =>
341 Effect.gen(function* () {
342 const parents =
343 matterId !== undefined && matterId.length > 0
344 ? [{ matterId }]
345 : yield* listActiveMatters();
346 for (const parent of parents) {
347 const parentId = parent.matterId ?? "";
348 const items = yield* listSavedQueries(parentId);
349 for (const item of items) {
350 if (yield* ownedByAlchemy(id, item.displayName)) {
351 return item;
352 }
353 }
354 }
355 return undefined;
356 });
357
358export const accountKey = (account: vault.HeldAccount) =>
359 (account.email ?? account.accountId ?? "").toLowerCase();
360
361export const sameAccounts = (
362 left: readonly vault.HeldAccount[] | undefined,
363 right: readonly vault.HeldAccount[] | undefined,
364) =>
365 jsonEqual(
366 [...(left ?? [])].map(accountKey).filter(Boolean).sort(),
367 [...(right ?? [])].map(accountKey).filter(Boolean).sort(),
368 );
369
370export const sameOrgUnit = (
371 left: vault.HeldOrgUnit | undefined,
372 right: vault.HeldOrgUnit | undefined,
373) => sameText(left?.orgUnitId, right?.orgUnitId);
374
375export const desiredAccounts = (
376 accounts: readonly vault.HeldAccount[] | undefined,
377): vault.HeldAccount[] | undefined => {
378 if (accounts === undefined) return undefined;
379 return accounts.map((account) => ({
380 email: account.email,
381 accountId: account.accountId,
382 }));
383};