1import * as firestore from "@distilled.cloud/gcp/firestore_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 databaseIdOf,
13 databaseNameOf,
14 lastSegment,
15 parseDatabaseName,
16 retryConcurrentChanges,
17} from "./internal.ts";
18
19const DEFAULT_RETENTION = "604800s";
20export type WeeklyRecurrenceDay =
21 | firestore.GoogleFirestoreAdminV1WeeklyRecurrenceDayEnum
22 | (string & {});
23
24export type DatabasesBackupScheduleProps = {
25 /**
26 * Parent database. Full name `projects/{project}/databases/{database}`
27 * or the database id. Immutable — changing it replaces the schedule.
28 */
29 database: string;
30 /**
31 * How long each backup is retained (e.g. `"604800s"` is 7 days). The
32 * maximum supported retention is 14 weeks.
33 * @default "604800s"
34 */
35 retention?: string;
36 /**
37 * Daily UTC recurrence. Mutually exclusive with `weeklyRecurrence`.
38 * Default when neither recurrence is set. Changing daily vs weekly
39 * replaces the schedule.
40 */
41 dailyRecurrence?: boolean;
42 /**
43 * Weekly UTC recurrence. Mutually exclusive with `dailyRecurrence`.
44 * Changing the day or switching from daily replaces the schedule.
45 */
46 weeklyRecurrence?: {
47 /** Day of week (`MONDAY` … `SUNDAY`). */
48 day: WeeklyRecurrenceDay;
49 };
50};
51
52export type DatabasesBackupSchedule = Resource<
53 "GCP.Firestore.DatabasesBackupSchedule",
54 DatabasesBackupScheduleProps,
55 {
56 /** Full resource name `.../databases/{database}/backupSchedules/{schedule}`. */
57 name: string;
58 /** Server-assigned backup schedule id. */
59 backupScheduleId: string;
60 /** Parent database resource name. */
61 database: string;
62 /** Parent database id. */
63 databaseId: string;
64 /** Project id. */
65 project: string;
66 /** Retention duration. */
67 retention: string | undefined;
68 /** Whether this schedule runs daily. */
69 dailyRecurrence: boolean;
70 /** Weekly recurrence day, if any. */
71 weeklyRecurrenceDay: string | undefined;
72 /** RFC3339 creation timestamp. */
73 createTime: string | undefined;
74 /** RFC3339 last-update timestamp. */
75 updateTime: string | undefined;
76 },
77 never,
78 Providers
79>;
80
81/**
82 * A scheduled backup for a Cloud Firestore database.
83 *
84 * At most one daily and one weekly schedule can exist per database.
85 * The schedule id is assigned by the API. Retention updates in place;
86 * switching daily vs weekly or moving to another database replaces the
87 * schedule. Backup schedules have no labels field: `read` reports a
88 * schedule it finds without prior state as unowned (adopt it with
89 * `--adopt`).
90 *
91 * ### Creating a Backup Schedule
92 * **Example:** Daily backups retained for 7 days
93 * ```typescript
94 * const database = yield* GCP.Firestore.Database("App", {
95 * location: "us-central1",
96 * });
97 * const schedule = yield* GCP.Firestore.DatabasesBackupSchedule("Nightly", {
98 * database: database.name,
99 * retention: "604800s",
100 * dailyRecurrence: true,
101 * });
102 * ```
103 *
104 * **Example:** Weekly backups
105 * ```typescript
106 * const schedule = yield* GCP.Firestore.DatabasesBackupSchedule("Weekly", {
107 * database: database.name,
108 * retention: "1209600s",
109 * weeklyRecurrence: { day: "SUNDAY" },
110 * });
111 * ```
112 *
113 * @resource
114 * @category Firestore
115 */
116export const DatabasesBackupSchedule = Resource<DatabasesBackupSchedule>(
117 "GCP.Firestore.DatabasesBackupSchedule",
118);
119
120export class BackupScheduleNotResolved extends Data.TaggedError(
121 "GCP.Firestore.BackupScheduleNotResolved",
122)<{
123 name: string;
124}> {}
125
126export class BackupScheduleStillExists extends Data.TaggedError(
127 "GCP.Firestore.BackupScheduleStillExists",
128)<{
129 name: string;
130}> {}
131
132const desiredWeeklyDay = (
133 news: DatabasesBackupScheduleProps,
134): string | undefined =>
135 news.weeklyRecurrence?.day !== undefined
136 ? news.weeklyRecurrence.day.toUpperCase()
137 : undefined;
138
139const desiredDaily = (news: DatabasesBackupScheduleProps) =>
140 news.weeklyRecurrence === undefined && news.dailyRecurrence !== false;
141
142const toAttrs = (
143 schedule: firestore.GoogleFirestoreAdminV1BackupSchedule,
144 project: string,
145): DatabasesBackupSchedule["Attributes"] => {
146 const name = schedule.name ?? "";
147 const parsed = parseDatabaseName(name);
148 return {
149 name,
150 backupScheduleId: parsed.backupScheduleId || lastSegment(name),
151 database: databaseNameOf(parsed.project || project, parsed.databaseId),
152 databaseId: parsed.databaseId,
153 project: parsed.project || project,
154 retention: schedule.retention,
155 dailyRecurrence: schedule.dailyRecurrence !== undefined,
156 weeklyRecurrenceDay: schedule.weeklyRecurrence?.day,
157 createTime: schedule.createTime,
158 updateTime: schedule.updateTime,
159 };
160};
161
162const getByName = (name: string) =>
163 firestore
164 .getProjectsDatabasesBackupSchedules({ name })
165 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
166
167const listOnDatabase = (parent: string) =>
168 firestore.listProjectsDatabasesBackupSchedules({ parent }).pipe(
169 Effect.map((page) => page.backupSchedules ?? []),
170 Effect.catchTag("NotFound", () =>
171 Effect.succeed([] as firestore.GoogleFirestoreAdminV1BackupSchedule[]),
172 ),
173 );
174
175const matchesDesired = (
176 schedule: firestore.GoogleFirestoreAdminV1BackupSchedule,
177 news: DatabasesBackupScheduleProps,
178) => {
179 const weeklyDay = desiredWeeklyDay(news);
180 if (weeklyDay !== undefined) {
181 return schedule.weeklyRecurrence?.day?.toUpperCase() === weeklyDay;
182 }
183 return schedule.dailyRecurrence !== undefined;
184};
185
186const waitUntilGone = (name: string) =>
187 getByName(name).pipe(
188 Effect.filterOrFail(
189 (schedule) => schedule === undefined,
190 () => new BackupScheduleStillExists({ name }),
191 ),
192 Effect.retry({
193 while: (error) =>
194 error._tag === "GCP.Firestore.BackupScheduleStillExists",
195 times: 8,
196 schedule: Schedule.spaced("2 seconds"),
197 }),
198 Effect.asVoid,
199 );
200
201export const DatabasesBackupScheduleProvider = () =>
202 Provider.succeed(DatabasesBackupSchedule, {
203 stables: [
204 "name",
205 "backupScheduleId",
206 "database",
207 "databaseId",
208 "project",
209 "createTime",
210 ],
211
212 diff: Effect.fn(function* ({ news, olds, output }) {
213 if (!isResolved(news)) return undefined;
214 const previousDatabase = databaseIdOf(
215 olds?.database ?? output?.database ?? output?.databaseId ?? "",
216 );
217 const nextDatabase = databaseIdOf(news.database);
218 const previousWeekly = (
219 olds?.weeklyRecurrence?.day ??
220 output?.weeklyRecurrenceDay ??
221 ""
222 ).toUpperCase();
223 const nextWeekly = (news.weeklyRecurrence?.day ?? "").toUpperCase();
224 const previousDaily =
225 olds?.weeklyRecurrence === undefined &&
226 (olds?.dailyRecurrence !== false || output?.dailyRecurrence === true);
227 const nextDaily = desiredDaily(news);
228 if (
229 (previousDatabase.length > 0 && previousDatabase !== nextDatabase) ||
230 previousWeekly !== nextWeekly ||
231 previousDaily !== nextDaily
232 ) {
233 return { action: "replace" as const };
234 }
235 return undefined;
236 }),
237
238 read: Effect.fn(function* ({ output }) {
239 const env = yield* GcpEnvironment.current;
240 const name = output?.name;
241 if (name === undefined || name.length === 0) return undefined;
242 const existing = yield* getByName(name);
243 if (existing === undefined) return undefined;
244 const attrs = toAttrs(existing, env.project);
245 // No labels field: without prior state it may not be ours.
246 return output !== undefined ? attrs : Unowned(attrs);
247 }),
248
249 reconcile: Effect.fn(function* ({ news, output }) {
250 const env = yield* GcpEnvironment.current;
251 const parent = databaseNameOf(env.project, news.database);
252 const weeklyDay = desiredWeeklyDay(news);
253 const daily = desiredDaily(news);
254 const retention = news.retention ?? DEFAULT_RETENTION;
255 let current =
256 output?.name !== undefined ? yield* getByName(output.name) : undefined;
257
258 if (current === undefined) {
259 const existing = yield* listOnDatabase(parent);
260 current = existing.find((schedule) => matchesDesired(schedule, news));
261 }
262
263 if (current === undefined) {
264 const created = yield* firestore
265 .createProjectsDatabasesBackupSchedules({
266 parent,
267 body: {
268 retention,
269 dailyRecurrence:
270 daily && weeklyDay === undefined ? {} : undefined,
271 weeklyRecurrence:
272 weeklyDay !== undefined ? { day: weeklyDay } : undefined,
273 },
274 })
275 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
276 if (created?.name !== undefined) {
277 current = created;
278 } else {
279 const listed = yield* listOnDatabase(parent);
280 current = listed.find((schedule) => matchesDesired(schedule, news));
281 }
282 }
283
284 if (current === undefined || current.name === undefined) {
285 return yield* new BackupScheduleNotResolved({
286 name: `${parent}/backupSchedules`,
287 });
288 }
289
290 const name = current.name;
291 if ((current.retention ?? "") !== retention) {
292 current = yield* retryConcurrentChanges(
293 firestore.patchProjectsDatabasesBackupSchedules({
294 name,
295 updateMask: "retention",
296 body: { retention },
297 }),
298 );
299 }
300
301 return toAttrs(current, env.project);
302 }),
303
304 delete: Effect.fn(function* ({ output }) {
305 yield* firestore
306 .deleteProjectsDatabasesBackupSchedules({ name: output.name })
307 .pipe(Effect.catchTag("NotFound", () => Effect.void));
308 yield* waitUntilGone(output.name);
309 }),
310 });