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 { GcpEnvironment } from "../Environment.ts";
11import type { Providers } from "../Providers.ts";
12import {
13 backupName,
14 databaseIdOf,
15 databaseNameOf,
16 instanceIdOf,
17 instanceName,
18 listAlchemyInstances,
19 MAX_BACKUP_ID_LENGTH,
20 parentOwned,
21 parseResourceName,
22 retryConcurrentChanges,
23 toPhysicalId,
24 waitForOperation,
25} from "./operations.ts";
26
27const DEFAULT_EXPIRE_MS = 7 * 24 * 60 * 60 * 1000;
28
29export type BackupEncryptionConfig = {
30 /**
31 * Encryption type (`USE_DATABASE_ENCRYPTION`,
32 * `GOOGLE_DEFAULT_ENCRYPTION`, `CUSTOMER_MANAGED_ENCRYPTION`).
33 */
34 encryptionType?:
35 | spanner.CreateProjectsInstancesBackupsEncryptionConfig_encryptionTypeEnum
36 | (string & {});
37 /**
38 * Cloud KMS key. Use only when the key location matches the instance
39 * config exactly. Prefer `kmsKeyNames` for new backups.
40 */
41 kmsKeyName?: string;
42 /**
43 * Cloud KMS keys covering every region of the instance config.
44 */
45 kmsKeyNames?: string[];
46};
47
48export type InstancesBackupProps = {
49 /**
50 * Parent instance id or full name
51 * (`projects/{project}/instances/{instance}`). Must be the instance
52 * that owns `database`. Immutable — changing it replaces the backup.
53 */
54 instance: string;
55 /**
56 * Source database id or full name
57 * (`projects/{project}/instances/{instance}/databases/{database}`).
58 * Immutable — changing it replaces the backup.
59 */
60 database: string;
61 /**
62 * Backup id (the `{backup}` segment of
63 * `projects/{project}/instances/{instance}/backups/{backup}`). If
64 * omitted, a unique name is generated. Must match
65 * `^[a-z][-a-z0-9]*[a-z0-9]$` (2–60 characters). Immutable —
66 * changing it replaces the backup.
67 */
68 backupId?: string;
69 /**
70 * RFC3339 expiration. Must be at least 6 hours and at most 366 days
71 * from create time. Defaults to 7 days from reconcile time on create.
72 */
73 expireTime?: string;
74 /**
75 * Externally consistent version timestamp. Defaults to create time.
76 * Immutable — changing it replaces the backup.
77 */
78 versionTime?: string;
79 /**
80 * Backup encryption. Immutable — changing it replaces the backup.
81 */
82 encryptionConfig?: BackupEncryptionConfig;
83};
84
85export type InstancesBackup = Resource<
86 "GCP.Spanner.InstancesBackup",
87 InstancesBackupProps,
88 {
89 /** Full resource name `projects/{project}/instances/{instance}/backups/{backup}`. */
90 name: string;
91 /** Backup id (last path segment). */
92 backupId: string;
93 /** Parent instance id. */
94 instanceId: string;
95 /** Project id. */
96 project: string;
97 /** Source database resource name. */
98 database: string | undefined;
99 /** RFC3339 expiration. */
100 expireTime: string | undefined;
101 /** Version timestamp captured in the backup. */
102 versionTime: string | undefined;
103 /** RFC3339 create time. */
104 createTime: string | undefined;
105 /** Size in bytes (decimal string). */
106 sizeBytes: string | undefined;
107 /** Bytes freed by deleting this backup. */
108 freeableSizeBytes: string | undefined;
109 /** Incremental exclusive size in bytes. */
110 exclusiveSizeBytes: string | undefined;
111 /** Server-reported state (`CREATING`, `READY`). */
112 state: string | undefined;
113 /** Databases restored from this backup. */
114 referencingDatabases: string[];
115 /** Encryption info. */
116 encryptionInfo: spanner.EncryptionInfo | undefined;
117 /** SQL dialect of the source database. */
118 databaseDialect: string | undefined;
119 /** Destination backups copying this backup. */
120 referencingBackups: string[];
121 /** Max allowed expiration. */
122 maxExpireTime: string | undefined;
123 /** Backup schedule names that produced this backup. */
124 backupSchedules: string[];
125 /** Incremental backup chain id, if any. */
126 incrementalBackupChainId: string | undefined;
127 /** Oldest retained version timestamp. */
128 oldestVersionTime: string | undefined;
129 /** Instance partitions stored in the backup. */
130 instancePartitions: string[];
131 /** Minimum edition required to restore. */
132 minimumRestorableEdition: string | undefined;
133 },
134 never,
135 Providers
136>;
137
138/**
139 * A Cloud Spanner backup of a database, stored on the parent instance.
140 *
141 * Backups have no labels field. Alchemy treats a backup as owned when
142 * its parent instance carries Alchemy labels, so `list` /
143 * `pnpm nuke:gcp` can find it. Changing `backupId`, `instance`,
144 * `database`, `versionTime`, or encryption replaces the backup.
145 * `expireTime` updates in place.
146 *
147 * ### Creating a Backup
148 * **Example:** 7-day backup of an existing database
149 * ```typescript
150 * const backup = yield* GCP.Spanner.InstancesBackup("Nightly", {
151 * instance: instance.instanceId,
152 * database: database.databaseId,
153 * });
154 * ```
155 *
156 * **Example:** Explicit id and expiration
157 * ```typescript
158 * const backup = yield* GCP.Spanner.InstancesBackup("Nightly", {
159 * instance: instance.name,
160 * database: database.name,
161 * backupId: "nightly",
162 * expireTime: "2026-12-31T00:00:00Z",
163 * });
164 * ```
165 *
166 * @resource
167 * @category Spanner
168 */
169export const InstancesBackup = Resource<InstancesBackup>(
170 "GCP.Spanner.InstancesBackup",
171);
172
173export class BackupNotResolved extends Data.TaggedError(
174 "GCP.Spanner.BackupNotResolved",
175)<{
176 name: string;
177}> {}
178
179export class BackupNotReady extends Data.TaggedError(
180 "GCP.Spanner.BackupNotReady",
181)<{
182 name: string;
183 state: string;
184}> {}
185
186export class BackupStillExists extends Data.TaggedError(
187 "GCP.Spanner.BackupStillExists",
188)<{
189 name: string;
190}> {}
191
192const toId = (id: string, backupId: string | undefined, existing?: string) =>
193 toPhysicalId(id, backupId, existing, MAX_BACKUP_ID_LENGTH);
194
195const defaultExpireTime = () =>
196 Effect.sync(() => new Date(Date.now() + DEFAULT_EXPIRE_MS).toISOString());
197
198const encryptionKey = (config: BackupEncryptionConfig | undefined) =>
199 JSON.stringify({
200 encryptionType: (config?.encryptionType ?? "").toUpperCase(),
201 kmsKeyName: config?.kmsKeyName ?? "",
202 kmsKeyNames: [...(config?.kmsKeyNames ?? [])].sort(),
203 });
204
205const toAttrs = (
206 backup: spanner.Backup,
207 project: string,
208): InstancesBackup["Attributes"] => {
209 const name = backup.name ?? "";
210 const parsed = parseResourceName(name);
211 return {
212 name,
213 backupId: parsed.backupId,
214 instanceId: parsed.instanceId,
215 project: parsed.project || project,
216 database: backup.database,
217 expireTime: backup.expireTime,
218 versionTime: backup.versionTime,
219 createTime: backup.createTime,
220 sizeBytes: backup.sizeBytes,
221 freeableSizeBytes: backup.freeableSizeBytes,
222 exclusiveSizeBytes: backup.exclusiveSizeBytes,
223 state: backup.state,
224 referencingDatabases: backup.referencingDatabases ?? [],
225 encryptionInfo: backup.encryptionInfo,
226 databaseDialect: backup.databaseDialect,
227 referencingBackups: backup.referencingBackups ?? [],
228 maxExpireTime: backup.maxExpireTime,
229 backupSchedules: backup.backupSchedules ?? [],
230 incrementalBackupChainId: backup.incrementalBackupChainId,
231 oldestVersionTime: backup.oldestVersionTime,
232 instancePartitions: (backup.instancePartitions ?? [])
233 .map((item) => item.instancePartition)
234 .filter((item): item is string => typeof item === "string"),
235 minimumRestorableEdition: backup.minimumRestorableEdition,
236 };
237};
238
239const getByName = (name: string) =>
240 spanner
241 .getProjectsInstancesBackups({ name })
242 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
243
244const isBusy = (state: string | undefined) =>
245 state === "CREATING" || state === "STATE_UNSPECIFIED" || state === undefined;
246
247const waitUntilExists = (name: string) =>
248 getByName(name).pipe(
249 Effect.flatMap((backup) =>
250 backup
251 ? Effect.succeed(backup)
252 : Effect.fail(new BackupNotResolved({ name })),
253 ),
254 Effect.retry({
255 while: (error) => error._tag === "GCP.Spanner.BackupNotResolved",
256 times: 8,
257 schedule: Schedule.spaced("2 seconds"),
258 }),
259 );
260
261const waitUntilReady = (name: string) =>
262 getByName(name).pipe(
263 Effect.filterOrFail(
264 (backup): backup is spanner.Backup => backup !== undefined,
265 () => new BackupNotResolved({ name }),
266 ),
267 Effect.filterOrFail(
268 (backup) => !isBusy(backup.state),
269 (backup) =>
270 new BackupNotReady({
271 name,
272 state: backup.state ?? "STATE_UNSPECIFIED",
273 }),
274 ),
275 Effect.retry({
276 while: (error) =>
277 error._tag === "GCP.Spanner.BackupNotReady" ||
278 error._tag === "GCP.Spanner.BackupNotResolved",
279 times: 8,
280 schedule: Schedule.spaced("5 seconds"),
281 }),
282 );
283
284const refreshOrFail = (name: string) =>
285 getByName(name).pipe(
286 Effect.flatMap((backup) =>
287 backup
288 ? Effect.succeed(backup)
289 : Effect.fail(new BackupNotResolved({ name })),
290 ),
291 );
292
293const waitUntilGone = (name: string) =>
294 getByName(name).pipe(
295 Effect.flatMap((backup) =>
296 backup === undefined
297 ? Effect.void
298 : Effect.fail(new BackupStillExists({ name })),
299 ),
300 Effect.retry({
301 while: (error) => error._tag === "GCP.Spanner.BackupStillExists",
302 times: 10,
303 schedule: Schedule.spaced("5 seconds"),
304 }),
305 );
306
307const expireEquals = (left: string | undefined, right: string | undefined) => {
308 if (left === right) return true;
309 if (left === undefined || right === undefined) return false;
310 const leftMs = Date.parse(left);
311 const rightMs = Date.parse(right);
312 if (Number.isNaN(leftMs) || Number.isNaN(rightMs)) return left === right;
313 return Math.abs(leftMs - rightMs) < 1000;
314};
315
316export const InstancesBackupProvider = () =>
317 Provider.succeed(InstancesBackup, {
318 stables: [
319 "name",
320 "backupId",
321 "instanceId",
322 "project",
323 "database",
324 "createTime",
325 "versionTime",
326 ],
327
328 diff: Effect.fn(function* ({ news, olds, output }) {
329 if (!isResolved(news)) return undefined;
330
331 const previousId = olds?.backupId ?? output?.backupId;
332 const nextId = news.backupId ?? previousId;
333 const previousInstance = instanceIdOf(
334 olds?.instance ?? output?.instanceId ?? "",
335 );
336 const nextInstance = instanceIdOf(news.instance);
337 const previousDatabase = databaseIdOf(
338 olds?.database ?? output?.database ?? "",
339 );
340 const nextDatabase = databaseIdOf(news.database);
341 const previousVersion = olds?.versionTime ?? output?.versionTime ?? "";
342 const nextVersion = news.versionTime ?? previousVersion;
343 const previousEncryption = encryptionKey(olds?.encryptionConfig);
344 const nextEncryption = encryptionKey(
345 news.encryptionConfig ?? olds?.encryptionConfig,
346 );
347
348 const replace =
349 (previousId !== undefined &&
350 nextId !== undefined &&
351 previousId !== nextId) ||
352 (previousInstance.length > 0 && previousInstance !== nextInstance) ||
353 (previousDatabase.length > 0 && previousDatabase !== nextDatabase) ||
354 (news.versionTime !== undefined &&
355 previousVersion.length > 0 &&
356 previousVersion !== nextVersion) ||
357 (news.encryptionConfig !== undefined &&
358 previousEncryption !== nextEncryption);
359
360 if (!replace) return undefined;
361 return { action: "replace" as const };
362 }),
363
364 read: Effect.fn(function* ({ id, olds, output }) {
365 const env = yield* GcpEnvironment.current;
366 const backupId = yield* toId(id, olds?.backupId, output?.backupId);
367 const instanceId = instanceIdOf(
368 olds?.instance ?? output?.instanceId ?? "",
369 );
370 if (instanceId.length === 0) return undefined;
371 const name =
372 output?.name ?? backupName(env.project, instanceId, backupId);
373 const existing = yield* getByName(name);
374 if (existing === undefined) return undefined;
375 const attrs = toAttrs(existing, env.project);
376 return (yield* parentOwned(instanceName(attrs.project, attrs.instanceId)))
377 ? attrs
378 : Unowned(attrs);
379 }),
380
381 list: () =>
382 Effect.gen(function* () {
383 const env = yield* GcpEnvironment.current;
384 const instances = yield* listAlchemyInstances(env.project);
385 const pages = yield* Effect.forEach(
386 instances,
387 (instance) => {
388 const parent = instance.name;
389 if (parent === undefined || parent.length === 0) {
390 return Effect.succeed([] as InstancesBackup["Attributes"][]);
391 }
392 return spanner.listProjectsInstancesBackups
393 .pages({
394 parent,
395 pageSize: 1000,
396 })
397 .pipe(
398 Stream.flatMap((page) =>
399 Stream.fromIterable(page.backups ?? []),
400 ),
401 Stream.map((backup) => toAttrs(backup, env.project)),
402 Stream.runCollect,
403 Effect.map((chunk) => Array.from(chunk)),
404 Effect.catchTag("NotFound", () =>
405 Effect.succeed([] as InstancesBackup["Attributes"][]),
406 ),
407 );
408 },
409 { concurrency: 4 },
410 );
411 return pages.flat();
412 }),
413
414 reconcile: Effect.fn(function* ({ id, news, output }) {
415 const env = yield* GcpEnvironment.current;
416 const instanceId = instanceIdOf(news.instance);
417 const backupId = yield* toId(id, news.backupId, output?.backupId);
418 const name = backupName(env.project, instanceId, backupId);
419 const parent = instanceName(env.project, instanceId);
420 const database = databaseNameOf(
421 env.project,
422 news.instance,
423 news.database,
424 );
425 const expireTime = news.expireTime ?? (yield* defaultExpireTime());
426
427 let current = yield* getByName(output?.name ?? name);
428
429 if (current === undefined) {
430 const created = yield* spanner
431 .createProjectsInstancesBackups({
432 parent,
433 backupId,
434 "encryptionConfig.encryptionType":
435 news.encryptionConfig?.encryptionType,
436 "encryptionConfig.kmsKeyName": news.encryptionConfig?.kmsKeyName,
437 "encryptionConfig.kmsKeyNames": news.encryptionConfig?.kmsKeyNames,
438 body: {
439 database,
440 expireTime,
441 versionTime: news.versionTime,
442 },
443 })
444 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
445 if (created !== undefined && created.done === true) {
446 yield* waitForOperation(created, { alreadyExistsOk: true });
447 }
448 current = yield* waitUntilExists(name);
449 }
450
451 if (isBusy(current.state)) {
452 current = yield* waitUntilReady(name).pipe(
453 Effect.catchTag(
454 ["GCP.Spanner.BackupNotReady", "GCP.Spanner.BackupNotResolved"],
455 () => refreshOrFail(name),
456 ),
457 );
458 }
459
460 if (
461 news.expireTime !== undefined &&
462 !expireEquals(current.expireTime, news.expireTime)
463 ) {
464 current = yield* retryConcurrentChanges(
465 spanner.patchProjectsInstancesBackups({
466 name,
467 updateMask: "expire_time",
468 body: {
469 expireTime: news.expireTime,
470 },
471 }),
472 );
473 }
474
475 return toAttrs(current, env.project);
476 }),
477
478 delete: Effect.fn(function* ({ output }) {
479 yield* spanner.deleteProjectsInstancesBackups({ name: output.name }).pipe(
480 Effect.catchTag("NotFound", () => Effect.void),
481 Effect.retry({
482 while: (error) => error._tag === "Conflict",
483 times: 8,
484 schedule: Schedule.spaced("5 seconds"),
485 }),
486 );
487 yield* waitUntilGone(output.name);
488 }),
489 });