| 1 | import * as firestore from "@distilled.cloud/gcp/firestore_v1"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Schedule from "effect/Schedule"; |
| 4 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 5 | import { alchemyLabelKeys, createInternalLabels } from "../Labels.ts"; |
| 6 | |
| 7 | export const MAX_ID_LENGTH = 63; |
| 8 | |
| 9 | export const lastSegment = (value: string) => { |
| 10 | const trimmed = value.replace(/\/+$/, ""); |
| 11 | const parts = trimmed.split("/"); |
| 12 | return parts[parts.length - 1] || trimmed; |
| 13 | }; |
| 14 | |
| 15 | export 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 | |
| 33 | export const databaseIdOf = (value: string) => |
| 34 | value.includes("/databases/") |
| 35 | ? parseDatabaseName(value).databaseId |
| 36 | : lastSegment(value); |
| 37 | |
| 38 | export const databaseResourceName = (project: string, databaseId: string) => |
| 39 | `projects/${project}/databases/${databaseId}`; |
| 40 | |
| 41 | export 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 | |
| 49 | export 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 | |
| 67 | export 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 | |
| 86 | export const jsonEqual = (left: unknown, right: unknown) => |
| 87 | JSON.stringify(left ?? null) === JSON.stringify(right ?? null); |
| 88 | |
| 89 | export 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 | |
| 97 | export 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 | |
| 105 | export 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 | |
| 120 | export { alchemyLabelKeys, createInternalLabels }; |