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 backupScheduleName,
14 databaseIdOf,
15 databaseName,
16 instanceIdOf,
17 instanceName,
18 listAlchemyDatabases,
19 MAX_BACKUP_SCHEDULE_ID_LENGTH,
20 parentOwned,
21 parseResourceName,
22 retryConcurrentChanges,
23 toPhysicalId,
24} from "./operations.ts";
25
26const DEFAULT_CRON = "0 2 * * *";
27const DEFAULT_RETENTION = "604800s";
28
29export type BackupScheduleEncryptionConfig = {
30 /**
31 * Encryption type (`USE_DATABASE_ENCRYPTION`,
32 * `GOOGLE_DEFAULT_ENCRYPTION`, `CUSTOMER_MANAGED_ENCRYPTION`).
33 */
34 encryptionType?:
35 | spanner.CreateBackupEncryptionConfigEncryptionTypeEnum
36 | (string & {});
37 /** Cloud KMS key. Prefer `kmsKeyNames` for new schedules. */
38 kmsKeyName?: string;
39 /** Cloud KMS keys covering every region of the instance config. */
40 kmsKeyNames?: string[];
41};
42
43export type BackupScheduleSpec = {
44 /**
45 * Cron schedule in UTC. Full backups must be at least 12 hours
46 * apart; incremental backups at least 4 hours apart.
47 * @default "0 2 * * *"
48 */
49 cron?: string;
50};
51
52export type InstancesDatabasesBackupScheduleProps = {
53 /**
54 * Parent instance id or full name. Immutable — changing it replaces
55 * the schedule.
56 */
57 instance: string;
58 /**
59 * Parent database id or full name. Immutable — changing it replaces
60 * the schedule.
61 */
62 database: string;
63 /**
64 * Backup schedule id (the `{schedule}` segment of
65 * `.../databases/{database}/backupSchedules/{schedule}`). If omitted,
66 * a unique name is generated. Must match `^[a-z][-a-z0-9]*[a-z0-9]$`
67 * (2–60 characters). Immutable — changing it replaces the schedule.
68 */
69 backupScheduleId?: string;
70 /**
71 * Cron-style schedule specification.
72 */
73 spec?: BackupScheduleSpec;
74 /**
75 * Retention duration of created backups (`604800s` is 7 days). Must
76 * be at least 6 hours and at most 366 days.
77 * @default "604800s"
78 */
79 retentionDuration?: string;
80 /**
81 * Encryption applied to backups created by this schedule.
82 */
83 encryptionConfig?: BackupScheduleEncryptionConfig;
84 /**
85 * When true, create incremental backup chains (Enterprise Plus).
86 * Default creates full backups. Immutable — changing it replaces
87 * the schedule.
88 * @default false
89 */
90 incremental?: boolean;
91};
92
93export type InstancesDatabasesBackupSchedule = Resource<
94 "GCP.Spanner.InstancesDatabasesBackupSchedule",
95 InstancesDatabasesBackupScheduleProps,
96 {
97 /** Full resource name `.../databases/{database}/backupSchedules/{schedule}`. */
98 name: string;
99 /** Backup schedule id (last path segment). */
100 backupScheduleId: string;
101 /** Parent database id. */
102 databaseId: string;
103 /** Parent instance id. */
104 instanceId: string;
105 /** Project id. */
106 project: string;
107 /** Cron text, if set. */
108 cron: string | undefined;
109 /** Cron time zone (UTC). */
110 timeZone: string | undefined;
111 /** Creation window after the version time. */
112 creationWindow: string | undefined;
113 /** Retention duration. */
114 retentionDuration: string | undefined;
115 /** Encryption configuration. */
116 encryptionConfig: BackupScheduleEncryptionConfig | undefined;
117 /** Whether the schedule creates incremental chains. */
118 incremental: boolean;
119 /** RFC3339 last-update timestamp. */
120 updateTime: string | undefined;
121 },
122 never,
123 Providers
124>;
125
126/**
127 * An automated backup schedule on a Cloud Spanner database.
128 *
129 * Schedules have no labels field. Alchemy treats a schedule as owned
130 * when its parent instance carries Alchemy labels, so `list` /
131 * `pnpm nuke:gcp` can find it. Changing `backupScheduleId`, `instance`,
132 * `database`, or `incremental` replaces the schedule. Cron, retention,
133 * and encryption update in place.
134 *
135 * ### Creating a Backup Schedule
136 * **Example:** Daily full backup retained for 7 days
137 * ```typescript
138 * const schedule = yield* GCP.Spanner.InstancesDatabasesBackupSchedule(
139 * "Nightly",
140 * {
141 * instance: instance.instanceId,
142 * database: database.databaseId,
143 * spec: { cron: "0 2 * * *" },
144 * retentionDuration: "604800s",
145 * },
146 * );
147 * ```
148 *
149 * **Example:** Explicit id
150 * ```typescript
151 * const schedule = yield* GCP.Spanner.InstancesDatabasesBackupSchedule(
152 * "Nightly",
153 * {
154 * instance: instance.name,
155 * database: database.name,
156 * backupScheduleId: "nightly",
157 * spec: { cron: "0 2 * * 0" },
158 * retentionDuration: "1209600s",
159 * },
160 * );
161 * ```
162 *
163 * @resource
164 * @category Spanner
165 */
166export const InstancesDatabasesBackupSchedule =
167 Resource<InstancesDatabasesBackupSchedule>(
168 "GCP.Spanner.InstancesDatabasesBackupSchedule",
169 );
170
171export class BackupScheduleNotResolved extends Data.TaggedError(
172 "GCP.Spanner.BackupScheduleNotResolved",
173)<{
174 name: string;
175}> {}
176
177const toId = (
178 id: string,
179 backupScheduleId: string | undefined,
180 existing?: string,
181) =>
182 toPhysicalId(id, backupScheduleId, existing, MAX_BACKUP_SCHEDULE_ID_LENGTH);
183
184const encryptionOf = (
185 config: spanner.CreateBackupEncryptionConfig | undefined,
186): BackupScheduleEncryptionConfig | undefined => {
187 if (config === undefined) return undefined;
188 if (
189 config.encryptionType === undefined &&
190 (config.kmsKeyName === undefined || config.kmsKeyName.length === 0) &&
191 (config.kmsKeyNames === undefined || config.kmsKeyNames.length === 0)
192 ) {
193 return undefined;
194 }
195 return {
196 encryptionType: config.encryptionType,
197 kmsKeyName: config.kmsKeyName,
198 kmsKeyNames: config.kmsKeyNames,
199 };
200};
201
202const encryptionKey = (config: BackupScheduleEncryptionConfig | undefined) =>
203 JSON.stringify({
204 encryptionType: (config?.encryptionType ?? "").toUpperCase(),
205 kmsKeyName: config?.kmsKeyName ?? "",
206 kmsKeyNames: [...(config?.kmsKeyNames ?? [])].sort(),
207 });
208
209const toAttrs = (
210 schedule: spanner.BackupSchedule,
211 project: string,
212): InstancesDatabasesBackupSchedule["Attributes"] => {
213 const name = schedule.name ?? "";
214 const parsed = parseResourceName(name);
215 return {
216 name,
217 backupScheduleId: parsed.backupScheduleId,
218 databaseId: parsed.databaseId,
219 instanceId: parsed.instanceId,
220 project: parsed.project || project,
221 cron: schedule.spec?.cronSpec?.text,
222 timeZone: schedule.spec?.cronSpec?.timeZone,
223 creationWindow: schedule.spec?.cronSpec?.creationWindow,
224 retentionDuration: schedule.retentionDuration,
225 encryptionConfig: encryptionOf(schedule.encryptionConfig),
226 incremental: schedule.incrementalBackupSpec !== undefined,
227 updateTime: schedule.updateTime,
228 };
229};
230
231const getByName = (name: string) =>
232 spanner
233 .getProjectsInstancesDatabasesBackupSchedules({ name })
234 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
235
236const toBody = (
237 news: InstancesDatabasesBackupScheduleProps,
238): spanner.BackupSchedule => {
239 const incremental = news.incremental === true;
240 const body: spanner.BackupSchedule = {
241 spec: {
242 cronSpec: {
243 text: news.spec?.cron ?? DEFAULT_CRON,
244 },
245 },
246 retentionDuration: news.retentionDuration ?? DEFAULT_RETENTION,
247 encryptionConfig: news.encryptionConfig,
248 };
249 if (incremental) {
250 body.incrementalBackupSpec = {};
251 } else {
252 body.fullBackupSpec = {};
253 }
254 return body;
255};
256
257export const InstancesDatabasesBackupScheduleProvider = () =>
258 Provider.succeed(InstancesDatabasesBackupSchedule, {
259 stables: [
260 "name",
261 "backupScheduleId",
262 "databaseId",
263 "instanceId",
264 "project",
265 ],
266
267 diff: Effect.fn(function* ({ news, olds, output }) {
268 if (!isResolved(news)) return undefined;
269
270 const previousId = olds?.backupScheduleId ?? output?.backupScheduleId;
271 const nextId = news.backupScheduleId ?? previousId;
272 const previousInstance = instanceIdOf(
273 olds?.instance ?? output?.instanceId ?? "",
274 );
275 const nextInstance = instanceIdOf(news.instance);
276 const previousDatabase = databaseIdOf(
277 olds?.database ?? output?.databaseId ?? "",
278 );
279 const nextDatabase = databaseIdOf(news.database);
280 const previousIncremental = olds?.incremental ?? output?.incremental;
281 const nextIncremental = news.incremental ?? previousIncremental;
282
283 const replace =
284 (previousId !== undefined &&
285 nextId !== undefined &&
286 previousId !== nextId) ||
287 (previousInstance.length > 0 && previousInstance !== nextInstance) ||
288 (previousDatabase.length > 0 && previousDatabase !== nextDatabase) ||
289 (previousIncremental !== undefined &&
290 nextIncremental !== undefined &&
291 previousIncremental !== nextIncremental);
292
293 if (!replace) return undefined;
294 return { action: "replace" as const };
295 }),
296
297 read: Effect.fn(function* ({ id, olds, output }) {
298 const env = yield* GcpEnvironment.current;
299 const backupScheduleId = yield* toId(
300 id,
301 olds?.backupScheduleId,
302 output?.backupScheduleId,
303 );
304 const instanceId = instanceIdOf(
305 olds?.instance ?? output?.instanceId ?? "",
306 );
307 const databaseId = databaseIdOf(
308 olds?.database ?? output?.databaseId ?? "",
309 );
310 if (instanceId.length === 0 || databaseId.length === 0) return undefined;
311 const name =
312 output?.name ??
313 backupScheduleName(
314 env.project,
315 instanceId,
316 databaseId,
317 backupScheduleId,
318 );
319 const existing = yield* getByName(name);
320 if (existing === undefined) return undefined;
321 const attrs = toAttrs(existing, env.project);
322 return (yield* parentOwned(instanceName(attrs.project, attrs.instanceId)))
323 ? attrs
324 : Unowned(attrs);
325 }),
326
327 list: () =>
328 Effect.gen(function* () {
329 const env = yield* GcpEnvironment.current;
330 const databases = yield* listAlchemyDatabases(env.project);
331 const pages = yield* Effect.forEach(
332 databases,
333 (database) => {
334 const parent = database.name;
335 if (parent === undefined || parent.length === 0) {
336 return Effect.succeed(
337 [] as InstancesDatabasesBackupSchedule["Attributes"][],
338 );
339 }
340 return spanner.listProjectsInstancesDatabasesBackupSchedules
341 .pages({
342 parent,
343 pageSize: 1000,
344 })
345 .pipe(
346 Stream.flatMap((page) =>
347 Stream.fromIterable(page.backupSchedules ?? []),
348 ),
349 Stream.map((schedule) => toAttrs(schedule, env.project)),
350 Stream.runCollect,
351 Effect.map((chunk) => Array.from(chunk)),
352 Effect.catchTag("NotFound", () =>
353 Effect.succeed(
354 [] as InstancesDatabasesBackupSchedule["Attributes"][],
355 ),
356 ),
357 );
358 },
359 { concurrency: 4 },
360 );
361 return pages.flat();
362 }),
363
364 reconcile: Effect.fn(function* ({ id, news, output }) {
365 const env = yield* GcpEnvironment.current;
366 const instanceId = instanceIdOf(news.instance);
367 const databaseId = databaseIdOf(news.database);
368 const backupScheduleId = yield* toId(
369 id,
370 news.backupScheduleId,
371 output?.backupScheduleId,
372 );
373 const parent = databaseName(env.project, instanceId, databaseId);
374 const name = backupScheduleName(
375 env.project,
376 instanceId,
377 databaseId,
378 backupScheduleId,
379 );
380 const desiredCron = news.spec?.cron ?? DEFAULT_CRON;
381 const desiredRetention = news.retentionDuration ?? DEFAULT_RETENTION;
382
383 let current = yield* getByName(output?.name ?? name);
384
385 if (current === undefined) {
386 current = yield* spanner
387 .createProjectsInstancesDatabasesBackupSchedules({
388 parent,
389 backupScheduleId,
390 body: toBody(news),
391 })
392 .pipe(
393 Effect.catchTag("Conflict", () => getByName(name)),
394 Effect.flatMap((schedule) =>
395 schedule
396 ? Effect.succeed(schedule)
397 : Effect.fail(new BackupScheduleNotResolved({ name })),
398 ),
399 );
400 }
401
402 const observedCron = current.spec?.cronSpec?.text ?? "";
403 const observedRetention = current.retentionDuration ?? "";
404 const encryptionChanged =
405 news.encryptionConfig !== undefined &&
406 encryptionKey(encryptionOf(current.encryptionConfig)) !==
407 encryptionKey(news.encryptionConfig);
408 const cronChanged = observedCron !== desiredCron;
409 const retentionChanged = observedRetention !== desiredRetention;
410
411 if (cronChanged || retentionChanged || encryptionChanged) {
412 const fieldMask = [
413 cronChanged ? "spec.cron_spec.text" : undefined,
414 retentionChanged ? "retention_duration" : undefined,
415 encryptionChanged ? "encryption_config" : undefined,
416 ].filter((field): field is string => field !== undefined);
417 const patchBody: spanner.BackupSchedule = {
418 name,
419 };
420 if (cronChanged) {
421 patchBody.spec = { cronSpec: { text: desiredCron } };
422 }
423 if (retentionChanged) {
424 patchBody.retentionDuration = desiredRetention;
425 }
426 if (encryptionChanged) {
427 patchBody.encryptionConfig = news.encryptionConfig;
428 }
429 current = yield* retryConcurrentChanges(
430 spanner.patchProjectsInstancesDatabasesBackupSchedules({
431 name,
432 updateMask: fieldMask.join(","),
433 body: patchBody,
434 }),
435 );
436 }
437
438 return toAttrs(current, env.project);
439 }),
440
441 delete: Effect.fn(function* ({ output }) {
442 yield* spanner
443 .deleteProjectsInstancesDatabasesBackupSchedules({
444 name: output.name,
445 })
446 .pipe(
447 Effect.catchTag("NotFound", () => Effect.void),
448 Effect.retry({
449 while: (error) => error._tag === "Conflict",
450 times: 8,
451 schedule: Schedule.spaced("2 seconds"),
452 }),
453 );
454 }),
455 });