1import * as firestore from "@distilled.cloud/gcp/firestore_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { createPhysicalName } from "../../PhysicalName.ts";
5import { alchemyLabelKeys, createInternalLabels } from "../Labels.ts";
6
7export const MAX_ID_LENGTH = 63;
8
9export const lastSegment = (value: string) => {
10 const trimmed = value.replace(/\/+$/, "");
11 const parts = trimmed.split("/");
12 return parts[parts.length - 1] || trimmed;
13};
14
15export const parseDatabaseName = (name: string) => {
16 const parts = name.split("/").filter((part) => part.length > 0);
17 const after = (segment: string) => {
18 const at = parts.lastIndexOf(segment);
19 return at >= 0 && parts[at + 1] ? parts[at + 1]! : "";
20 };
21 const project = after("projects");
22 const databaseId = after("databases");
23 return {
24 project,
25 databaseId: databaseId || lastSegment(name),
26 collectionGroup: after("collectionGroups"),
27 indexId: after("indexes"),
28 backupScheduleId: after("backupSchedules"),
29 userCredsId: after("userCreds"),
30 };
31};
32
33export const databaseIdOf = (value: string) =>
34 value.includes("/databases/")
35 ? parseDatabaseName(value).databaseId
36 : lastSegment(value);
37
38export const databaseResourceName = (project: string, databaseId: string) =>
39 `projects/${project}/databases/${databaseId}`;
40
41export const databaseNameOf = (project: string, database: string) => {
42 if (database.includes("/databases/")) {
43 const parsed = parseDatabaseName(database);
44 return databaseResourceName(parsed.project || project, parsed.databaseId);
45 }
46 return databaseResourceName(project, lastSegment(database));
47};
48
49export const rfc1035 = (name: string, maxLength = MAX_ID_LENGTH): string => {
50 let next = name
51 .toLowerCase()
52 .replace(/[^a-z0-9-]/g, "-")
53 .replace(/-+/g, "-")
54 .replace(/^-+|-+$/g, "");
55 if (!/^[a-z]/.test(next)) next = `f${next}`;
56 next = next.slice(0, maxLength).replace(/-+$/g, "");
57 if (next.length === 0) next = "firestore";
58 if (next.length < 4) {
59 next = `${next}xxxx`.slice(0, maxLength).replace(/-+$/g, "");
60 }
61 if (!/[a-z0-9]$/.test(next)) {
62 next = `${next.slice(0, maxLength - 1)}0`;
63 }
64 return next.slice(0, maxLength);
65};
66
67export const toResourceId = (
68 id: string,
69 explicit: string | undefined,
70 existing: string | undefined,
71 maxLength = MAX_ID_LENGTH,
72) =>
73 Effect.gen(function* () {
74 if (explicit !== undefined) return explicit;
75 if (existing !== undefined) return existing;
76 return rfc1035(
77 yield* createPhysicalName({
78 id,
79 maxLength,
80 lowercase: true,
81 }),
82 maxLength,
83 );
84 });
85
86export const jsonEqual = (left: unknown, right: unknown) =>
87 JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
88
89export const stringFromMap = (
90 map: firestore.DocumentMap | undefined,
91 key: string,
92): string | undefined => {
93 const value = map?.[key];
94 return typeof value === "string" && value.length > 0 ? value : undefined;
95};
96
97export const getDatabaseByName = (name: string) =>
98 firestore.getProjectsDatabases({ name }).pipe(
99 Effect.map((database) =>
100 database.deleteTime !== undefined ? undefined : database,
101 ),
102 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
103 );
104
105export const retryConcurrentChanges = <
106 A,
107 E extends { readonly _tag: string },
108 R,
109>(
110 effect: Effect.Effect<A, E, R>,
111) =>
112 effect.pipe(
113 Effect.retry({
114 while: (error) => error._tag === "Conflict",
115 times: 8,
116 schedule: Schedule.spaced("2 seconds"),
117 }),
118 );
119
120export { alchemyLabelKeys, createInternalLabels };