1import * as spanner from "@distilled.cloud/gcp/spanner_v1";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { createPhysicalName } from "../../PhysicalName.ts";
6import { ALCHEMY_LABEL_PREFIX } from "../Labels.ts";
7import { waitForOperation as waitForGcpOperation } from "../Operation.ts";
8
9/** Default instance config: the regional config of the stack's GCP region. */
10export const defaultConfigId = (region: string) => `regional-${region}`;
11export const DEFAULT_PROCESSING_UNITS = 100;
12export const DEFAULT_PARTITION_PROCESSING_UNITS = 1000;
13export const MAX_INSTANCE_ID_LENGTH = 64;
14export const MAX_INSTANCE_CONFIG_ID_LENGTH = 64;
15export const MAX_BACKUP_ID_LENGTH = 60;
16export const MAX_BACKUP_SCHEDULE_ID_LENGTH = 60;
17export const MAX_PARTITION_ID_LENGTH = 64;
18export const MAX_DISPLAY_NAME_LENGTH = 30;
19export const MIN_DISPLAY_NAME_LENGTH = 4;
20export const CUSTOM_CONFIG_PREFIX = "custom-";
21
22export const lastSegment = (value: string) => {
23 const trimmed = value.replace(/\/+$/, "");
24 const parts = trimmed.split("/");
25 return parts[parts.length - 1] || trimmed;
26};
27
28export 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
56export const instanceIdOf = (value: string) =>
57 value.includes("/instances/")
58 ? parseResourceName(value).instanceId
59 : lastSegment(value);
60
61export const databaseIdOf = (value: string) =>
62 value.includes("/databases/")
63 ? parseResourceName(value).databaseId
64 : lastSegment(value);
65
66export const configIdOf = (config: string | undefined, region: string) =>
67 lastSegment(config ?? defaultConfigId(region)).toLowerCase();
68
69export const instanceName = (project: string, instanceId: string) =>
70 `projects/${project}/instances/${instanceId}`;
71
72export 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
80export const databaseName = (
81 project: string,
82 instanceId: string,
83 databaseId: string,
84) => `${instanceName(project, instanceId)}/databases/${databaseId}`;
85
86export 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
102export const backupName = (
103 project: string,
104 instanceId: string,
105 backupId: string,
106) => `${instanceName(project, instanceId)}/backups/${backupId}`;
107
108export const backupScheduleName = (
109 project: string,
110 instanceId: string,
111 databaseId: string,
112 scheduleId: string,
113) =>
114 `${databaseName(project, instanceId, databaseId)}/backupSchedules/${scheduleId}`;
115
116export const instanceConfigName = (project: string, configId: string) =>
117 `projects/${project}/instanceConfigs/${configId}`;
118
119export 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
129export const instancePartitionName = (
130 project: string,
131 instanceId: string,
132 partitionId: string,
133) => `${instanceName(project, instanceId)}/instancePartitions/${partitionId}`;
134
135export 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
147export 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
161export 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
180export 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
201export 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
210export const normalizeEnum = (value: string | undefined, fallback: string) => {
211 const next = (value ?? fallback).toUpperCase();
212 return next.endsWith("_UNSPECIFIED") ? fallback : next;
213};
214
215export const hasAlchemyPrefix = (
216 labels: Record<string, string | undefined> | null | undefined,
217) =>
218 Object.keys(labels ?? {}).some((key) => key.startsWith(ALCHEMY_LABEL_PREFIX));
219
220const 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 */
240export 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
266export 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
281export const getInstanceByName = (name: string) =>
282 spanner
283 .getProjectsInstances({ name })
284 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
285
286export 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
299export 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
329export const parentOwned = (instanceNameValue: string) =>
330 getInstanceByName(instanceNameValue).pipe(
331 Effect.map((instance) =>
332 instance === undefined ? true : hasAlchemyPrefix(instance.labels),
333 ),
334 );