1import * as spanner from "@distilled.cloud/gcp/spanner_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import { Unowned } from "../../AdoptPolicy.ts";
7import { isResolved } from "../../Diff.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { tagRecord } from "../../Tags.ts";
11import { GcpEnvironment } from "../Environment.ts";
12import {
13 createInternalLabels,
14 diffLabels,
15 hasAlchemyLabels,
16 stripInternalLabels,
17 toLabels,
18} from "../Labels.ts";
19import type { Providers } from "../Providers.ts";
20import {
21 configIdOf,
22 configNameOf,
23 instanceConfigName,
24 parseResourceName,
25 retryConcurrentChanges,
26 toConfigId,
27 waitForOperation,
28} from "./operations.ts";
29
30export type ReplicaInfo = {
31 /** Replica location (e.g. `us-central1`). */
32 location?: string;
33 /** Replica type (`READ_WRITE`, `READ_ONLY`, `WITNESS`). */
34 type?: spanner.ReplicaInfoTypeEnum | (string & {});
35 /** Whether this location is the default leader. */
36 defaultLeaderLocation?: boolean;
37};
38
39export type InstanceConfigProps = {
40 /**
41 * Instance config id (the `{config}` segment of
42 * `projects/{project}/instanceConfigs/{config}`). User-managed ids must
43 * start with `custom-` and match `custom-[-a-z0-9]*[a-z0-9]` (2–64
44 * characters). If omitted, a unique `custom-` name is generated.
45 * Immutable — changing it replaces the config.
46 */
47 instanceConfigId?: string;
48 /**
49 * User-facing display name. Defaults to the instance config id.
50 */
51 displayName?: string;
52 /**
53 * Google-managed base config id (`regional-us-central1`) or full name.
54 * Immutable — changing it replaces the config.
55 * @default `regional-{region}` for the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
56 */
57 baseConfig?: string;
58 /**
59 * Replica placement. Must include every replica from `baseConfig` plus
60 * at least one of its optional replicas. When omitted, Alchemy copies
61 * the base replicas and appends the first optional replica.
62 * Immutable — changing them replaces the config.
63 */
64 replicas?: ReplicaInfo[];
65 /**
66 * User labels. Alchemy ownership labels are merged in automatically.
67 */
68 labels?: Record<string, string>;
69};
70
71export type InstanceConfig = Resource<
72 "GCP.Spanner.InstanceConfig",
73 InstanceConfigProps,
74 {
75 /** Full resource name `projects/{project}/instanceConfigs/{config}`. */
76 name: string;
77 /** Instance config id (last path segment). */
78 instanceConfigId: string;
79 /** Project id. */
80 project: string;
81 /** User-facing display name. */
82 displayName: string | undefined;
83 /** `GOOGLE_MANAGED` or `USER_MANAGED`. */
84 configType: string | undefined;
85 /** Replica placement. */
86 replicas: ReplicaInfo[];
87 /** Optional replicas advertised by Google-managed configs. */
88 optionalReplicas: ReplicaInfo[];
89 /** Full base config name. */
90 baseConfig: string | undefined;
91 /** User labels (Alchemy ownership labels stripped). */
92 labels: Record<string, string>;
93 /** Allowed `default_leader` values. */
94 leaderOptions: string[];
95 /** Whether a create/update is in flight. */
96 reconciling: boolean | undefined;
97 /** Current state (`CREATING`, `READY`). */
98 state: string | undefined;
99 /** Whether free instances can be created in this config. */
100 freeInstanceAvailability: string | undefined;
101 /** Quorum type (`REGION`, `DUAL_REGION`, `MULTI_REGION`). */
102 quorumType: string | undefined;
103 /** Storage limit in bytes per processing unit. */
104 storageLimitPerProcessingUnit: string | undefined;
105 },
106 never,
107 Providers
108>;
109
110/**
111 * A user-managed Cloud Spanner instance configuration.
112 *
113 * User-managed configs clone a Google-managed `baseConfig` and add at
114 * least one optional read-only replica. Only `displayName` and `labels`
115 * update in place; changing `instanceConfigId`, `baseConfig`, or
116 * `replicas` replaces the config. Create, update, and delete are
117 * long-running. Google-managed configs cannot be created or deleted.
118 *
119 * ### Creating an Instance Config
120 * **Example:** Clone `regional-us-central1` with an optional replica
121 * ```typescript
122 * const config = yield* GCP.Spanner.InstanceConfig("Custom", {
123 * baseConfig: "regional-us-central1",
124 * displayName: "custom-us-central1",
125 * labels: { env: "test" },
126 * });
127 * ```
128 *
129 * **Example:** Explicit id and replica list
130 * ```typescript
131 * const config = yield* GCP.Spanner.InstanceConfig("Custom", {
132 * instanceConfigId: "custom-us-central1-ro",
133 * baseConfig: "regional-us-central1",
134 * replicas: [
135 * { location: "us-central1", type: "READ_WRITE" },
136 * { location: "us-central1", type: "READ_WRITE" },
137 * { location: "us-central1", type: "READ_WRITE" },
138 * { location: "us-east1", type: "READ_ONLY" },
139 * ],
140 * });
141 * ```
142 *
143 * @resource
144 * @category Spanner
145 */
146export const InstanceConfig = Resource<InstanceConfig>(
147 "GCP.Spanner.InstanceConfig",
148);
149
150export class InstanceConfigNotResolved extends Data.TaggedError(
151 "GCP.Spanner.InstanceConfigNotResolved",
152)<{
153 name: string;
154}> {}
155
156export class InstanceConfigNotReady extends Data.TaggedError(
157 "GCP.Spanner.InstanceConfigNotReady",
158)<{
159 name: string;
160 state: string;
161}> {}
162
163export class InstanceConfigStillExists extends Data.TaggedError(
164 "GCP.Spanner.InstanceConfigStillExists",
165)<{
166 name: string;
167}> {}
168
169export class InstanceConfigNoOptionalReplicas extends Data.TaggedError(
170 "GCP.Spanner.InstanceConfigNoOptionalReplicas",
171)<{
172 baseConfig: string;
173}> {}
174
175const userLabels = (
176 labels: Record<string, string | undefined> | null | undefined,
177): Record<string, string> => stripInternalLabels(tagRecord(labels));
178
179const replicaOf = (replica: spanner.ReplicaInfo): ReplicaInfo => ({
180 location: replica.location,
181 type: replica.type,
182 defaultLeaderLocation: replica.defaultLeaderLocation,
183});
184
185const replicasOf = (
186 items: spanner.ReplicaInfoList | undefined,
187): ReplicaInfo[] => (items ?? []).map(replicaOf);
188
189const replicaKey = (replicas: ReplicaInfo[] | undefined) =>
190 JSON.stringify(
191 (replicas ?? [])
192 .map((replica) => ({
193 location: (replica.location ?? "").toLowerCase(),
194 type: (replica.type ?? "").toUpperCase(),
195 defaultLeaderLocation: replica.defaultLeaderLocation === true,
196 }))
197 .sort(
198 (left, right) =>
199 left.location.localeCompare(right.location) ||
200 left.type.localeCompare(right.type),
201 ),
202 );
203
204const toAttrs = (
205 config: spanner.InstanceConfig,
206 project: string,
207): InstanceConfig["Attributes"] => {
208 const name = config.name ?? "";
209 const parsed = parseResourceName(name);
210 return {
211 name,
212 instanceConfigId: parsed.instanceConfigId,
213 project,
214 displayName: config.displayName,
215 configType: config.configType,
216 replicas: replicasOf(config.replicas),
217 optionalReplicas: replicasOf(config.optionalReplicas),
218 baseConfig: config.baseConfig,
219 labels: userLabels(config.labels),
220 leaderOptions: config.leaderOptions ?? [],
221 reconciling: config.reconciling,
222 state: config.state,
223 freeInstanceAvailability: config.freeInstanceAvailability,
224 quorumType: config.quorumType,
225 storageLimitPerProcessingUnit: config.storageLimitPerProcessingUnit,
226 };
227};
228
229const getByName = (name: string) =>
230 spanner
231 .getProjectsInstanceConfigs({ name })
232 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
233
234const waitUntilExists = (name: string) =>
235 getByName(name).pipe(
236 Effect.flatMap((config) =>
237 config
238 ? Effect.succeed(config)
239 : Effect.fail(new InstanceConfigNotResolved({ name })),
240 ),
241 Effect.retry({
242 while: (error) => error._tag === "GCP.Spanner.InstanceConfigNotResolved",
243 times: 8,
244 schedule: Schedule.spaced("2 seconds"),
245 }),
246 );
247
248const isReady = (config: spanner.InstanceConfig) =>
249 (config.state ?? "STATE_UNSPECIFIED") === "READY" &&
250 config.reconciling !== true;
251
252const waitUntilReady = (name: string) =>
253 getByName(name).pipe(
254 Effect.filterOrFail(
255 (config): config is spanner.InstanceConfig => config !== undefined,
256 () => new InstanceConfigNotResolved({ name }),
257 ),
258 Effect.filterOrFail(
259 (config) => isReady(config),
260 (config) =>
261 new InstanceConfigNotReady({
262 name,
263 state: config.state ?? "STATE_UNSPECIFIED",
264 }),
265 ),
266 Effect.retry({
267 while: (error) =>
268 error._tag === "GCP.Spanner.InstanceConfigNotReady" ||
269 error._tag === "GCP.Spanner.InstanceConfigNotResolved",
270 times: 10,
271 schedule: Schedule.spaced("8 seconds"),
272 }),
273 );
274
275const waitUntilGone = (name: string) =>
276 getByName(name).pipe(
277 Effect.flatMap((config) =>
278 config === undefined
279 ? Effect.void
280 : Effect.fail(new InstanceConfigStillExists({ name })),
281 ),
282 Effect.retry({
283 while: (error) => error._tag === "GCP.Spanner.InstanceConfigStillExists",
284 times: 10,
285 schedule: Schedule.spaced("8 seconds"),
286 }),
287 );
288
289const desiredReplicas = (
290 project: string,
291 region: string,
292 news: InstanceConfigProps,
293): Effect.Effect<
294 ReplicaInfo[],
295 InstanceConfigNoOptionalReplicas | spanner.GetProjectsInstanceConfigsError,
296 spanner.GcpOpContext
297> =>
298 Effect.gen(function* () {
299 if (news.replicas !== undefined && news.replicas.length > 0) {
300 return news.replicas;
301 }
302 const baseName = configNameOf(project, news.baseConfig, region);
303 const base = yield* spanner.getProjectsInstanceConfigs({ name: baseName });
304 const extra = (base.optionalReplicas ?? [])[0];
305 if (extra === undefined) {
306 return yield* new InstanceConfigNoOptionalReplicas({
307 baseConfig: baseName,
308 });
309 }
310 return [...replicasOf(base.replicas), replicaOf(extra)];
311 });
312
313export const InstanceConfigProvider = () =>
314 Provider.succeed(InstanceConfig, {
315 stables: [
316 "name",
317 "instanceConfigId",
318 "project",
319 "baseConfig",
320 "configType",
321 ],
322
323 diff: Effect.fn(function* ({ news, olds, output }) {
324 if (!isResolved(news)) return undefined;
325 const env = yield* GcpEnvironment.current;
326
327 const previousId = olds?.instanceConfigId ?? output?.instanceConfigId;
328 const nextId = news.instanceConfigId ?? previousId;
329 const previousBase = configIdOf(
330 olds?.baseConfig ?? output?.baseConfig,
331 env.region,
332 );
333 const nextBase = configIdOf(
334 news.baseConfig ?? output?.baseConfig,
335 env.region,
336 );
337 const replicasChanged =
338 news.replicas !== undefined &&
339 olds?.replicas !== undefined &&
340 replicaKey(news.replicas) !== replicaKey(olds.replicas);
341
342 const idChanged =
343 previousId !== undefined &&
344 nextId !== undefined &&
345 previousId !== nextId;
346 const replace = idChanged || previousBase !== nextBase || replicasChanged;
347 if (!replace) return undefined;
348 return {
349 action: "replace" as const,
350 deleteFirst:
351 previousBase !== nextBase ||
352 replicasChanged ||
353 (previousId !== undefined && nextId === previousId),
354 };
355 }),
356
357 read: Effect.fn(function* ({ id, olds, output }) {
358 const env = yield* GcpEnvironment.current;
359 const instanceConfigId = yield* toConfigId(
360 id,
361 olds?.instanceConfigId,
362 output?.instanceConfigId,
363 );
364 const name =
365 output?.name ?? instanceConfigName(env.project, instanceConfigId);
366 const existing = yield* getByName(name);
367 if (existing === undefined) return undefined;
368 const attrs = toAttrs(existing, env.project);
369 return (yield* hasAlchemyLabels(id, tagRecord(existing.labels)))
370 ? attrs
371 : Unowned(attrs);
372 }),
373
374 list: () =>
375 Effect.gen(function* () {
376 const env = yield* GcpEnvironment.current;
377 return yield* spanner.listProjectsInstanceConfigs
378 .pages({
379 parent: `projects/${env.project}`,
380 pageSize: 1000,
381 })
382 .pipe(
383 Stream.flatMap((page) =>
384 Stream.fromIterable(page.instanceConfigs ?? []),
385 ),
386 Stream.filter(
387 (config) =>
388 (config.configType ?? "") === "USER_MANAGED" &&
389 Object.keys(config.labels ?? {}).some((key) =>
390 key.startsWith("alchemy-"),
391 ),
392 ),
393 Stream.map((config) => toAttrs(config, env.project)),
394 Stream.runCollect,
395 Effect.map((chunk) => Array.from(chunk)),
396 Effect.catchTag("NotFound", () => Effect.succeed([])),
397 );
398 }),
399
400 reconcile: Effect.fn(function* ({ id, news, output }) {
401 const env = yield* GcpEnvironment.current;
402 const instanceConfigId = yield* toConfigId(
403 id,
404 news.instanceConfigId,
405 output?.instanceConfigId,
406 );
407 const name = instanceConfigName(env.project, instanceConfigId);
408 const displayName = news.displayName?.trim() || instanceConfigId;
409 const baseConfig = configNameOf(env.project, news.baseConfig, env.region);
410 const desiredLabels = {
411 ...toLabels(news.labels),
412 ...(yield* createInternalLabels(id)),
413 };
414
415 let current = yield* getByName(output?.name ?? name);
416
417 if (current === undefined) {
418 const replicas = yield* desiredReplicas(env.project, env.region, news);
419 const created = yield* spanner
420 .createProjectsInstanceConfigs({
421 parent: `projects/${env.project}`,
422 body: {
423 instanceConfigId,
424 instanceConfig: {
425 name,
426 displayName,
427 baseConfig,
428 replicas,
429 labels: desiredLabels,
430 },
431 },
432 })
433 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
434 if (created !== undefined) {
435 yield* waitForOperation(created, { alreadyExistsOk: true });
436 }
437 current = yield* waitUntilExists(name);
438 }
439
440 if (!isReady(current)) {
441 current = yield* waitUntilReady(name);
442 }
443
444 const observedLabels = tagRecord(current.labels);
445 const { upsert, removed } = diffLabels(observedLabels, desiredLabels);
446 const labelsChanged = upsert.length > 0 || removed.length > 0;
447 const displayNameChanged = (current.displayName ?? "") !== displayName;
448
449 if (labelsChanged || displayNameChanged) {
450 const fieldMask = [
451 labelsChanged ? "labels" : undefined,
452 displayNameChanged ? "display_name" : undefined,
453 ].filter((field): field is string => field !== undefined);
454 const patched = yield* retryConcurrentChanges(
455 spanner.patchProjectsInstanceConfigs({
456 name,
457 body: {
458 instanceConfig: {
459 name,
460 displayName,
461 labels: desiredLabels,
462 },
463 updateMask: fieldMask.join(","),
464 },
465 }),
466 );
467 yield* waitForOperation(patched);
468 current = yield* waitUntilReady(name);
469 }
470
471 return toAttrs(current, env.project);
472 }),
473
474 delete: Effect.fn(function* ({ output }) {
475 yield* spanner.deleteProjectsInstanceConfigs({ name: output.name }).pipe(
476 Effect.catchTag("NotFound", () => Effect.void),
477 Effect.retry({
478 while: (error) => error._tag === "Conflict",
479 times: 8,
480 schedule: Schedule.spaced("5 seconds"),
481 }),
482 );
483 yield* waitUntilGone(output.name);
484 }),
485 });