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 { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { GcpEnvironment } from "../Environment.ts";
11import { waitForOperation as waitForGcpOperation } from "../Operation.ts";
12import type { Providers } from "../Providers.ts";
13
14const DEFAULT_TYPE = "FIRESTORE_NATIVE";
15const DEFAULT_EDITION = "STANDARD";
16const DEFAULT_DELETE_PROTECTION = "DELETE_PROTECTION_DISABLED";
17const DEFAULT_APP_ENGINE = "DISABLED";
18const MAX_DATABASE_ID_LENGTH = 63;
19
20export type DatabaseType =
21 | firestore.GoogleFirestoreAdminV1DatabaseTypeEnum
22 | (string & {});
23export type DatabaseEdition =
24 | firestore.GoogleFirestoreAdminV1DatabaseDatabaseEditionEnum
25 | (string & {});
26export type DatabaseConcurrencyMode =
27 | firestore.GoogleFirestoreAdminV1DatabaseConcurrencyModeEnum
28 | (string & {});
29export type DatabasePointInTimeRecoveryEnablement =
30 | firestore.GoogleFirestoreAdminV1DatabasePointInTimeRecoveryEnablementEnum
31 | (string & {});
32export type DatabaseAppEngineIntegrationMode =
33 | firestore.GoogleFirestoreAdminV1DatabaseAppEngineIntegrationModeEnum
34 | (string & {});
35export type DatabaseDeleteProtectionState =
36 | firestore.GoogleFirestoreAdminV1DatabaseDeleteProtectionStateEnum
37 | (string & {});
38export type DatabaseRealtimeUpdatesMode =
39 | firestore.GoogleFirestoreAdminV1DatabaseRealtimeUpdatesModeEnum
40 | (string & {});
41export type DatabaseDataAccessMode =
42 | firestore.GoogleFirestoreAdminV1DatabaseFirestoreDataAccessModeEnum
43 | (string & {});
44
45export type DatabaseCmekConfig = {
46 /**
47 * Cloud KMS CryptoKey used to encrypt the database
48 * (`projects/{project}/locations/{location}/keyRings/{keyRing}/cryptoKeys/{cryptoKey}`).
49 * Must be in the same location as the database. Immutable — changing
50 * it replaces the database.
51 */
52 kmsKeyName?: string;
53};
54
55export type DatabaseProps = {
56 /**
57 * Database id (the `{database}` segment of
58 * `projects/{project}/databases/{database}`). If omitted, a unique name
59 * is generated from the stack, stage, and logical id. Must be 4-63
60 * characters, match `[a-z][a-z0-9-]*[a-z0-9]`, and must not look like a
61 * UUID. `"(default)"` is valid for the Standard edition default
62 * database. Immutable — changing it replaces the database.
63 */
64 databaseId?: string;
65 /**
66 * Location of the database (`us-central1`, `nam5`, `eur3`, …).
67 * Immutable — changing it replaces the database. `US-CENTRAL1` is
68 * accepted and normalized to `us-central1`.
69 * @default the stack's GCP region (`GCP.Region`, else the profile region, else `us-central1`)
70 */
71 location?: string;
72 /**
73 * Database type. Mode changes are only allowed when the database is
74 * empty.
75 * @default "FIRESTORE_NATIVE"
76 */
77 type?: DatabaseType;
78 /**
79 * Database edition. Immutable — changing it replaces the database.
80 * @default "STANDARD"
81 */
82 databaseEdition?: DatabaseEdition;
83 /**
84 * Default transaction concurrency mode. Defaults to `PESSIMISTIC` for
85 * Standard edition and `OPTIMISTIC` for Enterprise.
86 */
87 concurrencyMode?: DatabaseConcurrencyMode;
88 /**
89 * Point-in-time recovery. When enabled, version retention is 7 days
90 * instead of 1 hour.
91 * @default "POINT_IN_TIME_RECOVERY_DISABLED"
92 */
93 pointInTimeRecoveryEnablement?: DatabasePointInTimeRecoveryEnablement;
94 /**
95 * App Engine integration mode. `DISABLED` is the API default for
96 * databases created with the Firestore API.
97 * @default "DISABLED"
98 */
99 appEngineIntegrationMode?: DatabaseAppEngineIntegrationMode;
100 /**
101 * Delete protection. Alchemy defaults to disabled so `destroy` can
102 * delete the database.
103 * @default "DELETE_PROTECTION_DISABLED"
104 */
105 deleteProtectionState?: DatabaseDeleteProtectionState;
106 /**
107 * Default Realtime Updates mode. Immutable — changing it replaces the
108 * database.
109 */
110 realtimeUpdatesMode?: DatabaseRealtimeUpdatesMode;
111 /**
112 * Firestore API data access mode. Defaults to enabled on Standard
113 * edition and disabled on Enterprise.
114 */
115 firestoreDataAccessMode?: DatabaseDataAccessMode;
116 /**
117 * MongoDB-compatible API data access mode. Always disabled on Standard
118 * edition.
119 */
120 mongodbCompatibleDataAccessMode?: DatabaseDataAccessMode;
121 /**
122 * Customer-managed encryption. Immutable — changing it replaces the
123 * database.
124 */
125 cmekConfig?: DatabaseCmekConfig;
126};
127
128export type Database = Resource<
129 "GCP.Firestore.Database",
130 DatabaseProps,
131 {
132 /** Full resource name `projects/{project}/databases/{database}`. */
133 name: string;
134 /** Database id (last path segment). */
135 databaseId: string;
136 /** Project id. */
137 project: string;
138 /** Location id (`us-central1`, `nam5`, …). */
139 location: string;
140 /** Database type (`FIRESTORE_NATIVE`, `DATASTORE_MODE`). */
141 type: string;
142 /** Database edition (`STANDARD`, `ENTERPRISE`). */
143 databaseEdition: string | undefined;
144 /** Default concurrency mode. */
145 concurrencyMode: string | undefined;
146 /** Point-in-time recovery enablement. */
147 pointInTimeRecoveryEnablement: string | undefined;
148 /** App Engine integration mode. */
149 appEngineIntegrationMode: string | undefined;
150 /** Delete protection state. */
151 deleteProtectionState: string | undefined;
152 /** Realtime Updates mode. */
153 realtimeUpdatesMode: string | undefined;
154 /** Firestore API data access mode. */
155 firestoreDataAccessMode: string | undefined;
156 /** MongoDB-compatible API data access mode. */
157 mongodbCompatibleDataAccessMode: string | undefined;
158 /** CMEK key, if any. */
159 kmsKeyName: string | undefined;
160 /** System-generated UUID4. */
161 uid: string | undefined;
162 /** Datastore key prefix, if any. */
163 keyPrefix: string | undefined;
164 /** Whether this database is eligible for the free tier. */
165 freeTier: boolean | undefined;
166 /** Version retention period (e.g. `"3600s"`). */
167 versionRetentionPeriod: string | undefined;
168 /** Earliest PITR timestamp, if available. */
169 earliestVersionTime: string | undefined;
170 /** RFC3339 creation timestamp. */
171 createTime: string | undefined;
172 /** RFC3339 last-update timestamp. */
173 updateTime: string | undefined;
174 /** Server etag. */
175 etag: string | undefined;
176 },
177 never,
178 Providers
179>;
180
181/**
182 * A Cloud Firestore database.
183 *
184 * Firestore databases have no labels field and Alchemy writes no data
185 * into them: `read` reports a database it finds without prior state as
186 * unowned (adopt it with `--adopt`). Changing `databaseId`, `location`, `databaseEdition`, CMEK, or
187 * Realtime Updates mode replaces the database.
188 *
189 * Create, update, and delete are long-running operations — provisioning
190 * a named database typically takes tens of seconds.
191 *
192 * ### Creating a Database
193 * **Example:** Generated name
194 * ```typescript
195 * const database = yield* GCP.Firestore.Database("App", {});
196 * ```
197 *
198 * **Example:** Explicit id, location, and concurrency
199 * ```typescript
200 * const database = yield* GCP.Firestore.Database("App", {
201 * databaseId: "app-data",
202 * location: "us-central1",
203 * type: "FIRESTORE_NATIVE",
204 * concurrencyMode: "OPTIMISTIC",
205 * });
206 * ```
207 *
208 * ### Reading and Writing Documents
209 * **Example:** Upsert and read a document
210 * ```typescript
211 * const patchDocument = yield* GCP.Firestore.PatchDocument(database);
212 * yield* patchDocument({
213 * documentPath: "users/alice",
214 * body: { fields: { name: { stringValue: "Alice" } } },
215 * });
216 * const getDocument = yield* GCP.Firestore.GetDocument(database);
217 * const doc = yield* getDocument({ documentPath: "users/alice" });
218 * ```
219 *
220 * @resource
221 * @category Firestore
222 */
223export const Database = Resource<Database>("GCP.Firestore.Database");
224
225export class DatabaseNotResolved extends Data.TaggedError(
226 "GCP.Firestore.DatabaseNotResolved",
227)<{
228 name: string;
229}> {}
230
231export class DatabaseStillExists extends Data.TaggedError(
232 "GCP.Firestore.DatabaseStillExists",
233)<{
234 name: string;
235}> {}
236
237const lastSegment = (value: string) => {
238 const trimmed = value.replace(/\/+$/, "");
239 const parts = trimmed.split("/");
240 return parts[parts.length - 1] || trimmed;
241};
242
243const normalizeLocation = (location: string | undefined, fallback: string) =>
244 lastSegment(location ?? fallback).toLowerCase();
245
246const normalizeEnum = (value: string | undefined, fallback: string) => {
247 const next = (value ?? fallback).toUpperCase();
248 return next.endsWith("_UNSPECIFIED") ? fallback : next;
249};
250
251const normalizeType = (value: string | undefined) =>
252 normalizeEnum(value, DEFAULT_TYPE);
253
254const normalizeEdition = (value: string | undefined) =>
255 normalizeEnum(value, DEFAULT_EDITION);
256
257const resourceName = (project: string, databaseId: string) =>
258 `projects/${project}/databases/${databaseId}`;
259
260const parseName = (name: string) => {
261 const parts = name.split("/").filter((part) => part.length > 0);
262 const databasesAt = parts.lastIndexOf("databases");
263 const projectsAt = parts.lastIndexOf("projects");
264 return {
265 project:
266 projectsAt >= 0 && parts[projectsAt + 1] ? parts[projectsAt + 1]! : "",
267 databaseId:
268 databasesAt >= 0 && parts[databasesAt + 1]
269 ? parts[databasesAt + 1]!
270 : lastSegment(name),
271 };
272};
273
274const toDatabaseId = (
275 id: string,
276 databaseId: string | undefined,
277 existing?: string,
278) =>
279 Effect.gen(function* () {
280 if (databaseId !== undefined) return databaseId;
281 if (existing !== undefined) return existing;
282 const generated = yield* createPhysicalName({
283 id,
284 maxLength: MAX_DATABASE_ID_LENGTH,
285 lowercase: true,
286 });
287 let named = /^[a-z]/.test(generated) ? generated : `f${generated}`;
288 named = named.replace(/-+$/g, "").slice(0, MAX_DATABASE_ID_LENGTH);
289 named = named.replace(/-+$/g, "");
290 if (named.length < 4) {
291 named = `${named}dbxx`.slice(0, MAX_DATABASE_ID_LENGTH);
292 }
293 return named;
294 });
295
296const toAttrs = (
297 database: firestore.GoogleFirestoreAdminV1Database,
298 project: string,
299): Database["Attributes"] => {
300 const name = database.name ?? "";
301 const parsed = parseName(name);
302 return {
303 name,
304 databaseId: parsed.databaseId,
305 project: parsed.project || project,
306 location: normalizeLocation(database.locationId, ""),
307 type: normalizeType(database.type),
308 databaseEdition: database.databaseEdition,
309 concurrencyMode: database.concurrencyMode,
310 pointInTimeRecoveryEnablement: database.pointInTimeRecoveryEnablement,
311 appEngineIntegrationMode: database.appEngineIntegrationMode,
312 deleteProtectionState: database.deleteProtectionState,
313 realtimeUpdatesMode: database.realtimeUpdatesMode,
314 firestoreDataAccessMode: database.firestoreDataAccessMode,
315 mongodbCompatibleDataAccessMode: database.mongodbCompatibleDataAccessMode,
316 kmsKeyName: database.cmekConfig?.kmsKeyName,
317 uid: database.uid,
318 keyPrefix: database.keyPrefix,
319 freeTier: database.freeTier,
320 versionRetentionPeriod: database.versionRetentionPeriod,
321 earliestVersionTime: database.earliestVersionTime,
322 createTime: database.createTime,
323 updateTime: database.updateTime,
324 etag: database.etag,
325 };
326};
327
328const getByName = (name: string) =>
329 firestore.getProjectsDatabases({ name }).pipe(
330 Effect.map((database) =>
331 database.deleteTime !== undefined ? undefined : database,
332 ),
333 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
334 );
335
336/**
337 * Wait for a Firestore admin operation; named databases provision in tens
338 * of seconds to a few minutes. With `alreadyExistsOk`, ALREADY_EXISTS
339 * (code 6) counts as success (create race).
340 */
341const waitForOperation = (
342 operation: firestore.GoogleLongrunningOperation,
343 options?: { alreadyExistsOk?: boolean },
344) =>
345 waitForGcpOperation(
346 operation,
347 (name) => firestore.getProjectsDatabasesOperations({ name }),
348 { budget: "10 minutes" },
349 ).pipe(
350 Effect.catchIf(
351 (error) =>
352 options?.alreadyExistsOk === true &&
353 error._tag === "GCP.OperationFailed" &&
354 error.code === 6,
355 () => Effect.succeed(operation),
356 ),
357 );
358
359const waitUntilExists = (name: string) =>
360 getByName(name).pipe(
361 Effect.filterOrFail(
362 (database): database is firestore.GoogleFirestoreAdminV1Database =>
363 database !== undefined,
364 () => new DatabaseNotResolved({ name }),
365 ),
366 Effect.retry({
367 while: (error) => error._tag === "GCP.Firestore.DatabaseNotResolved",
368 times: 8,
369 schedule: Schedule.spaced("2 seconds"),
370 }),
371 );
372
373const waitUntilGone = (name: string) =>
374 getByName(name).pipe(
375 Effect.filterOrFail(
376 (database) => database === undefined,
377 () => new DatabaseStillExists({ name }),
378 ),
379 Effect.retry({
380 while: (error) => error._tag === "GCP.Firestore.DatabaseStillExists",
381 times: 10,
382 schedule: Schedule.spaced("8 seconds"),
383 }),
384 Effect.asVoid,
385 );
386
387const retryConcurrentChanges = <A, E extends { readonly _tag: string }, R>(
388 effect: Effect.Effect<A, E, R>,
389) =>
390 effect.pipe(
391 Effect.retry({
392 while: (error) => error._tag === "Conflict",
393 times: 8,
394 schedule: Schedule.spaced("5 seconds"),
395 }),
396 );
397
398const enumChanged = (
399 desired: string | undefined,
400 observed: string | undefined,
401) => {
402 if (desired === undefined) return false;
403 return normalizeEnum(observed, desired) !== normalizeEnum(desired, desired);
404};
405
406export const DatabaseProvider = () =>
407 Provider.succeed(Database, {
408 stables: [
409 "name",
410 "databaseId",
411 "project",
412 "location",
413 "uid",
414 "createTime",
415 "keyPrefix",
416 "databaseEdition",
417 ],
418
419 diff: Effect.fn(function* ({ news, olds, output }) {
420 const env = yield* GcpEnvironment.current;
421 if (!isResolved(news)) return undefined;
422
423 const previousId = olds?.databaseId ?? output?.databaseId;
424 const nextId = news.databaseId ?? previousId;
425 const previousLocation = normalizeLocation(
426 olds?.location ?? output?.location,
427 env.region,
428 );
429 const nextLocation = normalizeLocation(
430 news.location ?? output?.location,
431 env.region,
432 );
433 const previousEdition = normalizeEdition(
434 olds?.databaseEdition ?? output?.databaseEdition,
435 );
436 const nextEdition = normalizeEdition(
437 news.databaseEdition ?? output?.databaseEdition,
438 );
439 const previousKms =
440 olds?.cmekConfig?.kmsKeyName ?? output?.kmsKeyName ?? "";
441 const nextKms = news.cmekConfig?.kmsKeyName ?? previousKms;
442 const previousRealtime = (
443 olds?.realtimeUpdatesMode ??
444 output?.realtimeUpdatesMode ??
445 ""
446 ).toUpperCase();
447 const nextRealtime = (
448 news.realtimeUpdatesMode ??
449 output?.realtimeUpdatesMode ??
450 previousRealtime
451 ).toUpperCase();
452
453 const replace =
454 (previousId !== undefined &&
455 nextId !== undefined &&
456 nextId !== previousId) ||
457 previousLocation !== nextLocation ||
458 previousEdition !== nextEdition ||
459 previousKms !== nextKms ||
460 (news.realtimeUpdatesMode !== undefined &&
461 previousRealtime !== nextRealtime);
462
463 if (!replace) return undefined;
464 return {
465 action: "replace" as const,
466 deleteFirst:
467 previousId !== undefined &&
468 nextId !== undefined &&
469 nextId === previousId,
470 };
471 }),
472
473 read: Effect.fn(function* ({ id, olds, output }) {
474 const env = yield* GcpEnvironment.current;
475 const databaseId = yield* toDatabaseId(
476 id,
477 olds?.databaseId,
478 output?.databaseId,
479 );
480 const name = output?.name ?? resourceName(env.project, databaseId);
481 const existing = yield* getByName(name);
482 if (existing === undefined) return undefined;
483 const attrs = toAttrs(existing, env.project);
484 // No labels field: without prior state it may not be ours.
485 return output !== undefined ? attrs : Unowned(attrs);
486 }),
487
488 reconcile: Effect.fn(function* ({ id, news, output }) {
489 const env = yield* GcpEnvironment.current;
490 const databaseId = yield* toDatabaseId(
491 id,
492 news.databaseId,
493 output?.databaseId,
494 );
495 const location = normalizeLocation(
496 news.location ?? output?.location,
497 env.region,
498 );
499 const type = normalizeType(news.type);
500 const name = resourceName(env.project, databaseId);
501 const desiredProtection =
502 news.deleteProtectionState ?? DEFAULT_DELETE_PROTECTION;
503
504 let current = yield* getByName(name);
505
506 if (current === undefined) {
507 const created = yield* firestore
508 .createProjectsDatabases({
509 parent: `projects/${env.project}`,
510 databaseId,
511 body: {
512 locationId: location,
513 type,
514 databaseEdition: news.databaseEdition
515 ? normalizeEdition(news.databaseEdition)
516 : undefined,
517 concurrencyMode: news.concurrencyMode,
518 pointInTimeRecoveryEnablement: news.pointInTimeRecoveryEnablement,
519 appEngineIntegrationMode:
520 news.appEngineIntegrationMode ?? DEFAULT_APP_ENGINE,
521 deleteProtectionState: desiredProtection,
522 realtimeUpdatesMode: news.realtimeUpdatesMode,
523 firestoreDataAccessMode: news.firestoreDataAccessMode,
524 mongodbCompatibleDataAccessMode:
525 news.mongodbCompatibleDataAccessMode,
526 cmekConfig: news.cmekConfig?.kmsKeyName
527 ? { kmsKeyName: news.cmekConfig.kmsKeyName }
528 : undefined,
529 },
530 })
531 .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
532 if (created !== undefined) {
533 yield* waitForOperation(created, { alreadyExistsOk: true });
534 }
535 current = yield* waitUntilExists(name);
536 }
537
538 if (current === undefined) {
539 return yield* new DatabaseNotResolved({ name });
540 }
541
542 const mask: string[] = [];
543 const patchBody: firestore.GoogleFirestoreAdminV1Database = {};
544
545 if (enumChanged(type, current.type)) {
546 patchBody.type = type;
547 mask.push("type");
548 }
549 if (enumChanged(news.concurrencyMode, current.concurrencyMode)) {
550 patchBody.concurrencyMode = news.concurrencyMode;
551 mask.push("concurrencyMode");
552 }
553 if (
554 enumChanged(
555 news.pointInTimeRecoveryEnablement,
556 current.pointInTimeRecoveryEnablement,
557 )
558 ) {
559 patchBody.pointInTimeRecoveryEnablement =
560 news.pointInTimeRecoveryEnablement;
561 mask.push("pointInTimeRecoveryEnablement");
562 }
563 if (
564 enumChanged(
565 news.appEngineIntegrationMode,
566 current.appEngineIntegrationMode,
567 )
568 ) {
569 patchBody.appEngineIntegrationMode = news.appEngineIntegrationMode;
570 mask.push("appEngineIntegrationMode");
571 }
572 if (enumChanged(desiredProtection, current.deleteProtectionState)) {
573 patchBody.deleteProtectionState = desiredProtection;
574 mask.push("deleteProtectionState");
575 }
576 if (
577 enumChanged(
578 news.firestoreDataAccessMode,
579 current.firestoreDataAccessMode,
580 )
581 ) {
582 patchBody.firestoreDataAccessMode = news.firestoreDataAccessMode;
583 mask.push("firestoreDataAccessMode");
584 }
585 if (
586 enumChanged(
587 news.mongodbCompatibleDataAccessMode,
588 current.mongodbCompatibleDataAccessMode,
589 )
590 ) {
591 patchBody.mongodbCompatibleDataAccessMode =
592 news.mongodbCompatibleDataAccessMode;
593 mask.push("mongodbCompatibleDataAccessMode");
594 }
595
596 if (mask.length > 0) {
597 const patched = yield* retryConcurrentChanges(
598 firestore.patchProjectsDatabases({
599 name,
600 updateMask: mask.join(","),
601 body: patchBody,
602 }),
603 );
604 yield* waitForOperation(patched);
605 current = yield* waitUntilExists(name);
606 }
607
608 return toAttrs(current, env.project);
609 }),
610
611 delete: Effect.fn(function* ({ output }) {
612 const current = yield* getByName(output.name);
613 if (current === undefined) return;
614
615 if (
616 normalizeEnum(
617 current.deleteProtectionState,
618 DEFAULT_DELETE_PROTECTION,
619 ) === "DELETE_PROTECTION_ENABLED"
620 ) {
621 const patched = yield* retryConcurrentChanges(
622 firestore.patchProjectsDatabases({
623 name: output.name,
624 updateMask: "deleteProtectionState",
625 body: {
626 deleteProtectionState: DEFAULT_DELETE_PROTECTION,
627 },
628 }),
629 );
630 yield* waitForOperation(patched);
631 }
632
633 yield* retryConcurrentChanges(
634 firestore.deleteProjectsDatabases({ name: output.name }),
635 ).pipe(Effect.catchTag("NotFound", () => Effect.void));
636 // Soft-delete: GET reports `deleteTime` long before the LRO is done.
637 yield* waitUntilGone(output.name);
638 }),
639 });