| 1 | import * as spanner from "@distilled.cloud/gcp/spanner_v1"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Schedule from "effect/Schedule"; |
| 4 | import * as Stream from "effect/Stream"; |
| 5 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 6 | import { ALCHEMY_LABEL_PREFIX } from "../Labels.ts"; |
| 7 | import { waitForOperation as waitForGcpOperation } from "../Operation.ts"; |
| 8 | |
| 9 | /** Default instance config: the regional config of the stack's GCP region. */ |
| 10 | export const defaultConfigId = (region: string) => `regional-${region}`; |
| 11 | export const DEFAULT_PROCESSING_UNITS = 100; |
| 12 | export const DEFAULT_PARTITION_PROCESSING_UNITS = 1000; |
| 13 | export const MAX_INSTANCE_ID_LENGTH = 64; |
| 14 | export const MAX_INSTANCE_CONFIG_ID_LENGTH = 64; |
| 15 | export const MAX_BACKUP_ID_LENGTH = 60; |
| 16 | export const MAX_BACKUP_SCHEDULE_ID_LENGTH = 60; |
| 17 | export const MAX_PARTITION_ID_LENGTH = 64; |
| 18 | export const MAX_DISPLAY_NAME_LENGTH = 30; |
| 19 | export const MIN_DISPLAY_NAME_LENGTH = 4; |
| 20 | export const CUSTOM_CONFIG_PREFIX = "custom-"; |
| 21 | |
| 22 | export const lastSegment = (value: string) => { |
| 23 | const trimmed = value.replace(/\/+$/, ""); |
| 24 | const parts = trimmed.split("/"); |
| 25 | return parts[parts.length - 1] || trimmed; |
| 26 | }; |
| 27 | |
| 28 | export const parseResourceName = (name: string) => { |
| 29 | const parts = name.split("/").filter((part) => part.length > 0); |
| 30 | const after = (segment: string) => { |
| 31 | const at = parts.lastIndexOf(segment); |
| 32 | return at >= 0 && parts[at + 1] ? parts[at + 1]! : ""; |
| 33 | }; |
| 34 | const project = after("projects"); |
| 35 | const instanceId = after("instances"); |
| 36 | const databaseId = after("databases"); |
| 37 | return { |
| 38 | project, |
| 39 | instanceId, |
| 40 | databaseId, |
| 41 | backupId: after("backups"), |
| 42 | backupScheduleId: after("backupSchedules"), |
| 43 | instancePartitionId: after("instancePartitions"), |
| 44 | instanceConfigId: after("instanceConfigs"), |
| 45 | instance: |
| 46 | project && instanceId |
| 47 | ? `projects/${project}/instances/${instanceId}` |
| 48 | : "", |
| 49 | database: |
| 50 | project && instanceId && databaseId |
| 51 | ? `projects/${project}/instances/${instanceId}/databases/${databaseId}` |
| 52 | : "", |
| 53 | }; |
| 54 | }; |
| 55 | |
| 56 | export const instanceIdOf = (value: string) => |
| 57 | value.includes("/instances/") |
| 58 | ? parseResourceName(value).instanceId |
| 59 | : lastSegment(value); |
| 60 | |
| 61 | export const databaseIdOf = (value: string) => |
| 62 | value.includes("/databases/") |
| 63 | ? parseResourceName(value).databaseId |
| 64 | : lastSegment(value); |
| 65 | |
| 66 | export const configIdOf = (config: string | undefined, region: string) => |
| 67 | lastSegment(config ?? defaultConfigId(region)).toLowerCase(); |
| 68 | |
| 69 | export const instanceName = (project: string, instanceId: string) => |
| 70 | `projects/${project}/instances/${instanceId}`; |
| 71 | |
| 72 | export const instanceNameOf = (project: string, instance: string) => |
| 73 | instance.includes("/instances/") |
| 74 | ? instanceName( |
| 75 | parseResourceName(instance).project || project, |
| 76 | parseResourceName(instance).instanceId, |
| 77 | ) |
| 78 | : instanceName(project, lastSegment(instance)); |
| 79 | |
| 80 | export const databaseName = ( |
| 81 | project: string, |
| 82 | instanceId: string, |
| 83 | databaseId: string, |
| 84 | ) => `${instanceName(project, instanceId)}/databases/${databaseId}`; |
| 85 | |
| 86 | export const databaseNameOf = ( |
| 87 | project: string, |
| 88 | instance: string, |
| 89 | database: string, |
| 90 | ) => { |
| 91 | if (database.includes("/databases/")) { |
| 92 | const parsed = parseResourceName(database); |
| 93 | return databaseName( |
| 94 | parsed.project || project, |
| 95 | parsed.instanceId || instanceIdOf(instance), |
| 96 | parsed.databaseId, |
| 97 | ); |
| 98 | } |
| 99 | return databaseName(project, instanceIdOf(instance), lastSegment(database)); |
| 100 | }; |
| 101 | |
| 102 | export const backupName = ( |
| 103 | project: string, |
| 104 | instanceId: string, |
| 105 | backupId: string, |
| 106 | ) => `${instanceName(project, instanceId)}/backups/${backupId}`; |
| 107 | |
| 108 | export const backupScheduleName = ( |
| 109 | project: string, |
| 110 | instanceId: string, |
| 111 | databaseId: string, |
| 112 | scheduleId: string, |
| 113 | ) => |
| 114 | `${databaseName(project, instanceId, databaseId)}/backupSchedules/${scheduleId}`; |
| 115 | |
| 116 | export const instanceConfigName = (project: string, configId: string) => |
| 117 | `projects/${project}/instanceConfigs/${configId}`; |
| 118 | |
| 119 | export const configNameOf = ( |
| 120 | project: string, |
| 121 | config: string | undefined, |
| 122 | region: string, |
| 123 | ) => { |
| 124 | const raw = (config ?? defaultConfigId(region)).trim(); |
| 125 | if (raw.includes("/")) return raw; |
| 126 | return instanceConfigName(project, raw); |
| 127 | }; |
| 128 | |
| 129 | export const instancePartitionName = ( |
| 130 | project: string, |
| 131 | instanceId: string, |
| 132 | partitionId: string, |
| 133 | ) => `${instanceName(project, instanceId)}/instancePartitions/${partitionId}`; |
| 134 | |
| 135 | export const toSpannerId = (name: string, maxLength: number) => { |
| 136 | let next = name |
| 137 | .toLowerCase() |
| 138 | .replace(/[^a-z0-9-]/g, "-") |
| 139 | .replace(/-+/g, "-"); |
| 140 | next = next.replace(/^-+/, "").replace(/-+$/g, ""); |
| 141 | if (!/^[a-z]/.test(next)) next = `s${next}`; |
| 142 | next = next.slice(0, maxLength).replace(/-+$/g, ""); |
| 143 | if (next.length < 2) next = `${next}xx`.slice(0, maxLength); |
| 144 | return next; |
| 145 | }; |
| 146 | |
| 147 | export const toCustomConfigId = (name: string) => { |
| 148 | const maxBody = MAX_INSTANCE_CONFIG_ID_LENGTH - CUSTOM_CONFIG_PREFIX.length; |
| 149 | const body = toSpannerId(name, maxBody); |
| 150 | if (body.startsWith(CUSTOM_CONFIG_PREFIX)) { |
| 151 | return body.slice(0, MAX_INSTANCE_CONFIG_ID_LENGTH); |
| 152 | } |
| 153 | let next = `${CUSTOM_CONFIG_PREFIX}${body}`; |
| 154 | next = next.slice(0, MAX_INSTANCE_CONFIG_ID_LENGTH).replace(/-+$/g, ""); |
| 155 | if (!/[a-z0-9]$/.test(next)) { |
| 156 | next = `${next.slice(0, MAX_INSTANCE_CONFIG_ID_LENGTH - 1)}x`; |
| 157 | } |
| 158 | return next; |
| 159 | }; |
| 160 | |
| 161 | export const toPhysicalId = ( |
| 162 | id: string, |
| 163 | explicit: string | undefined, |
| 164 | existing: string | undefined, |
| 165 | maxLength: number, |
| 166 | ) => |
| 167 | Effect.gen(function* () { |
| 168 | if (explicit !== undefined) return explicit; |
| 169 | if (existing !== undefined) return existing; |
| 170 | return toSpannerId( |
| 171 | yield* createPhysicalName({ |
| 172 | id, |
| 173 | maxLength, |
| 174 | lowercase: true, |
| 175 | }), |
| 176 | maxLength, |
| 177 | ); |
| 178 | }); |
| 179 | |
| 180 | export const toConfigId = ( |
| 181 | id: string, |
| 182 | explicit: string | undefined, |
| 183 | existing?: string, |
| 184 | ) => |
| 185 | Effect.gen(function* () { |
| 186 | if (explicit !== undefined) { |
| 187 | return explicit.startsWith(CUSTOM_CONFIG_PREFIX) |
| 188 | ? explicit |
| 189 | : toCustomConfigId(explicit); |
| 190 | } |
| 191 | if (existing !== undefined) return existing; |
| 192 | return toCustomConfigId( |
| 193 | yield* createPhysicalName({ |
| 194 | id, |
| 195 | maxLength: MAX_INSTANCE_CONFIG_ID_LENGTH, |
| 196 | lowercase: true, |
| 197 | }), |
| 198 | ); |
| 199 | }); |
| 200 | |
| 201 | export const displayNameOf = (sourceId: string, displayName?: string) => { |
| 202 | const source = (displayName ?? sourceId).trim(); |
| 203 | let next = source.slice(0, MAX_DISPLAY_NAME_LENGTH); |
| 204 | if (next.length < MIN_DISPLAY_NAME_LENGTH) { |
| 205 | next = `${next}inst`.slice(0, MAX_DISPLAY_NAME_LENGTH); |
| 206 | } |
| 207 | return next; |
| 208 | }; |
| 209 | |
| 210 | export const normalizeEnum = (value: string | undefined, fallback: string) => { |
| 211 | const next = (value ?? fallback).toUpperCase(); |
| 212 | return next.endsWith("_UNSPECIFIED") ? fallback : next; |
| 213 | }; |
| 214 | |
| 215 | export const hasAlchemyPrefix = ( |
| 216 | labels: Record<string, string | undefined> | null | undefined, |
| 217 | ) => |
| 218 | Object.keys(labels ?? {}).some((key) => key.startsWith(ALCHEMY_LABEL_PREFIX)); |
| 219 | |
| 220 | const getOperation = (name: string) => { |
| 221 | if (name.includes("/instanceConfigs/")) { |
| 222 | return spanner.getProjectsInstanceConfigsOperations({ name }); |
| 223 | } |
| 224 | if (name.includes("/instancePartitions/")) { |
| 225 | return spanner.getProjectsInstancesInstancePartitionsOperations({ name }); |
| 226 | } |
| 227 | if (name.includes("/backups/")) { |
| 228 | return spanner.getProjectsInstancesBackupsOperations({ name }); |
| 229 | } |
| 230 | if (name.includes("/databases/")) { |
| 231 | return spanner.getProjectsInstancesDatabasesOperations({ name }); |
| 232 | } |
| 233 | return spanner.getProjectsInstancesOperations({ name }); |
| 234 | }; |
| 235 | |
| 236 | /** |
| 237 | * Wait for a Spanner operation. Instance and database provisioning can take |
| 238 | * many minutes, so the budget is generous. |
| 239 | */ |
| 240 | export const waitForOperation = ( |
| 241 | operation: spanner.Operation, |
| 242 | options?: { notFoundOk?: boolean; alreadyExistsOk?: boolean }, |
| 243 | ) => |
| 244 | waitForGcpOperation( |
| 245 | operation, |
| 246 | (name) => |
| 247 | getOperation(name).pipe( |
| 248 | Effect.catchTag("NotFound", (error) => |
| 249 | options?.notFoundOk === true |
| 250 | ? Effect.succeed<spanner.Operation>({ name, done: true }) |
| 251 | : Effect.fail(error), |
| 252 | ), |
| 253 | ), |
| 254 | { budget: "30 minutes" }, |
| 255 | ).pipe( |
| 256 | // ALREADY_EXISTS (6) / NOT_FOUND (5) when the caller tolerates them. |
| 257 | Effect.catchIf( |
| 258 | (error) => |
| 259 | error._tag === "GCP.OperationFailed" && |
| 260 | ((options?.alreadyExistsOk === true && error.code === 6) || |
| 261 | (options?.notFoundOk === true && error.code === 5)), |
| 262 | () => Effect.void, |
| 263 | ), |
| 264 | ); |
| 265 | |
| 266 | export const retryConcurrentChanges = < |
| 267 | A, |
| 268 | E extends { readonly _tag: string }, |
| 269 | R, |
| 270 | >( |
| 271 | effect: Effect.Effect<A, E, R>, |
| 272 | ) => |
| 273 | effect.pipe( |
| 274 | Effect.retry({ |
| 275 | while: (error) => error._tag === "Conflict", |
| 276 | times: 8, |
| 277 | schedule: Schedule.spaced("5 seconds"), |
| 278 | }), |
| 279 | ); |
| 280 | |
| 281 | export const getInstanceByName = (name: string) => |
| 282 | spanner |
| 283 | .getProjectsInstances({ name }) |
| 284 | .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined))); |
| 285 | |
| 286 | export const listAlchemyInstances = (project: string) => |
| 287 | spanner.listProjectsInstances |
| 288 | .pages({ |
| 289 | parent: `projects/${project}`, |
| 290 | pageSize: 1000, |
| 291 | }) |
| 292 | .pipe( |
| 293 | Stream.flatMap((page) => Stream.fromIterable(page.instances ?? [])), |
| 294 | Stream.filter((instance) => hasAlchemyPrefix(instance.labels)), |
| 295 | Stream.runCollect, |
| 296 | Effect.map((chunk) => Array.from(chunk)), |
| 297 | ); |
| 298 | |
| 299 | export const listAlchemyDatabases = (project: string) => |
| 300 | Effect.gen(function* () { |
| 301 | const instances = yield* listAlchemyInstances(project); |
| 302 | const pages = yield* Effect.forEach( |
| 303 | instances, |
| 304 | (instance) => { |
| 305 | const parent = instance.name; |
| 306 | if (parent === undefined || parent.length === 0) { |
| 307 | return Effect.succeed([] as spanner.Database[]); |
| 308 | } |
| 309 | return spanner.listProjectsInstancesDatabases |
| 310 | .pages({ |
| 311 | parent, |
| 312 | pageSize: 1000, |
| 313 | }) |
| 314 | .pipe( |
| 315 | Stream.flatMap((page) => Stream.fromIterable(page.databases ?? [])), |
| 316 | Stream.runCollect, |
| 317 | Effect.map((chunk) => Array.from(chunk)), |
| 318 | // The instance was deleted while listing. |
| 319 | Effect.catchTag("NotFound", () => |
| 320 | Effect.succeed([] as spanner.Database[]), |
| 321 | ), |
| 322 | ); |
| 323 | }, |
| 324 | { concurrency: 4 }, |
| 325 | ); |
| 326 | return pages.flat(); |
| 327 | }); |
| 328 | |
| 329 | export const parentOwned = (instanceNameValue: string) => |
| 330 | getInstanceByName(instanceNameValue).pipe( |
| 331 | Effect.map((instance) => |
| 332 | instance === undefined ? true : hasAlchemyPrefix(instance.labels), |
| 333 | ), |
| 334 | ); |