1import * as firestore from "@distilled.cloud/gcp/firestore_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import * as Schedule from "effect/Schedule";
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 toResourceId,
17} from "./internal.ts";
18
19export type DatabasesUserCredProps = {
20 /**
21 * Parent database. Full name `projects/{project}/databases/{database}`
22 * or the database id. User creds require an Enterprise edition
23 * database with MongoDB-compatible API access enabled. Immutable —
24 * changing it replaces the creds.
25 */
26 database: string;
27 /**
28 * User creds id (the `{user_creds}` segment of
29 * `.../databases/{database}/userCreds/{user_creds}`). If omitted, a
30 * unique name is generated. Must be 4-63 characters, match
31 * `[a-z][a-z0-9-]*[a-z0-9]`, and must not look like a UUID.
32 * Immutable — changing it replaces the creds.
33 */
34 userCredsId?: string;
35 /**
36 * Disable the creds. Enable/disable updates in place.
37 * @default false
38 */
39 disabled?: boolean;
40};
41
42export type DatabasesUserCred = Resource<
43 "GCP.Firestore.DatabasesUserCred",
44 DatabasesUserCredProps,
45 {
46 /** Full resource name `.../databases/{database}/userCreds/{user_creds}`. */
47 name: string;
48 /** User creds id (last path segment). */
49 userCredsId: string;
50 /** Parent database resource name. */
51 database: string;
52 /** Parent database id. */
53 databaseId: string;
54 /** Project id. */
55 project: string;
56 /** Whether the creds are disabled. */
57 disabled: boolean;
58 /** Server-reported state (`ENABLED`, `DISABLED`). */
59 state: string | undefined;
60 /**
61 * Plaintext server-generated password. Only populated on create and
62 * password reset; later reads preserve the last known value.
63 */
64 securePassword: string | undefined;
65 /** Resource Identity principal, if any. */
66 principal: string | undefined;
67 /** RFC3339 creation timestamp. */
68 createTime: string | undefined;
69 /** RFC3339 last-update timestamp. */
70 updateTime: string | undefined;
71 },
72 never,
73 Providers
74>;
75
76/**
77 * User credentials for a Cloud Firestore database with MongoDB
78 * compatibility.
79 *
80 * User creds are owned by the parent database and require Enterprise
81 * edition with the MongoDB-compatible API enabled. The plaintext
82 * password is returned only on create (and password reset); later
83 * reads keep the last known value from state. Enable/disable updates
84 * in place. Changing `userCredsId` or `database` replaces the creds.
85 *
86 * User creds have no labels field: `read` reports creds it finds without
87 * prior state as unowned (adopt them with `--adopt`).
88 *
89 * ### Creating User Creds
90 * **Example:** Enabled creds on an Enterprise database
91 * ```typescript
92 * const database = yield* GCP.Firestore.Database("App", {
93 * location: "us-central1",
94 * databaseEdition: "ENTERPRISE",
95 * mongodbCompatibleDataAccessMode: "DATA_ACCESS_MODE_ENABLED",
96 * });
97 * const creds = yield* GCP.Firestore.DatabasesUserCred("AppUser", {
98 * database: database.name,
99 * });
100 * ```
101 *
102 * **Example:** Disabled creds
103 * ```typescript
104 * const creds = yield* GCP.Firestore.DatabasesUserCred("AppUser", {
105 * database: database.name,
106 * disabled: true,
107 * });
108 * ```
109 *
110 * @resource
111 * @category Firestore
112 */
113export const DatabasesUserCred = Resource<DatabasesUserCred>(
114 "GCP.Firestore.DatabasesUserCred",
115);
116
117export class UserCredNotResolved extends Data.TaggedError(
118 "GCP.Firestore.UserCredNotResolved",
119)<{
120 name: string;
121}> {}
122
123export class UserCredStillExists extends Data.TaggedError(
124 "GCP.Firestore.UserCredStillExists",
125)<{
126 name: string;
127}> {}
128
129const resourceName = (databaseName: string, userCredsId: string) =>
130 `${databaseName}/userCreds/${userCredsId}`;
131
132const toAttrs = (
133 creds: firestore.GoogleFirestoreAdminV1UserCreds,
134 project: string,
135 securePassword?: string,
136): DatabasesUserCred["Attributes"] => {
137 const name = creds.name ?? "";
138 const parsed = parseDatabaseName(name);
139 const state = creds.state;
140 return {
141 name,
142 userCredsId: parsed.userCredsId || lastSegment(name),
143 database: databaseNameOf(parsed.project || project, parsed.databaseId),
144 databaseId: parsed.databaseId,
145 project: parsed.project || project,
146 disabled: state === "DISABLED",
147 state,
148 securePassword: creds.securePassword ?? securePassword,
149 principal: creds.resourceIdentity?.principal,
150 createTime: creds.createTime,
151 updateTime: creds.updateTime,
152 };
153};
154
155const getByName = (name: string) =>
156 firestore
157 .getProjectsDatabasesUserCreds({ name })
158 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
159
160const listOnDatabase = (parent: string) =>
161 firestore.listProjectsDatabasesUserCreds({ parent }).pipe(
162 Effect.map((page) => page.userCreds ?? []),
163 Effect.catchTag("NotFound", () =>
164 Effect.succeed([] as firestore.GoogleFirestoreAdminV1UserCreds[]),
165 ),
166 );
167
168const waitUntilGone = (name: string) =>
169 getByName(name).pipe(
170 Effect.filterOrFail(
171 (creds) => creds === undefined,
172 () => new UserCredStillExists({ name }),
173 ),
174 Effect.retry({
175 while: (error) => error._tag === "GCP.Firestore.UserCredStillExists",
176 times: 8,
177 schedule: Schedule.spaced("2 seconds"),
178 }),
179 Effect.asVoid,
180 );
181
182const syncEnabled = (
183 name: string,
184 disabled: boolean,
185 current: firestore.GoogleFirestoreAdminV1UserCreds,
186) => {
187 const isDisabled = current.state === "DISABLED";
188 if (isDisabled === disabled) return Effect.succeed(current);
189 return disabled
190 ? firestore.disableProjectsDatabasesUserCreds({ name, body: {} })
191 : firestore.enableProjectsDatabasesUserCreds({ name, body: {} });
192};
193
194export const DatabasesUserCredProvider = () =>
195 Provider.succeed(DatabasesUserCred, {
196 stables: [
197 "name",
198 "userCredsId",
199 "database",
200 "databaseId",
201 "project",
202 "createTime",
203 ],
204
205 diff: Effect.fn(function* ({ news, olds, output }) {
206 if (!isResolved(news)) return undefined;
207 const previousId = olds?.userCredsId ?? output?.userCredsId;
208 const nextId = news.userCredsId ?? previousId;
209 const previousDatabase = databaseIdOf(
210 olds?.database ?? output?.database ?? output?.databaseId ?? "",
211 );
212 const nextDatabase = databaseIdOf(news.database);
213 if (
214 (previousId !== undefined &&
215 nextId !== undefined &&
216 previousId !== nextId) ||
217 (previousDatabase.length > 0 && previousDatabase !== nextDatabase)
218 ) {
219 return { action: "replace" as const };
220 }
221 return undefined;
222 }),
223
224 read: Effect.fn(function* ({ id, olds, output }) {
225 const env = yield* GcpEnvironment.current;
226 const userCredsId = yield* toResourceId(
227 id,
228 olds?.userCredsId,
229 output?.userCredsId,
230 );
231 const databaseRef = olds?.database ?? output?.database;
232 const name =
233 output?.name ??
234 (databaseRef !== undefined
235 ? resourceName(databaseNameOf(env.project, databaseRef), userCredsId)
236 : undefined);
237 if (name === undefined) return undefined;
238 const existing = yield* getByName(name);
239 if (existing === undefined) return undefined;
240 const attrs = toAttrs(existing, env.project, output?.securePassword);
241 // No labels field: without prior state they may not be ours.
242 return output !== undefined ? attrs : Unowned(attrs);
243 }),
244
245 reconcile: Effect.fn(function* ({ id, news, output }) {
246 const env = yield* GcpEnvironment.current;
247 const parent = databaseNameOf(env.project, news.database);
248 const userCredsId = yield* toResourceId(
249 id,
250 news.userCredsId,
251 output?.userCredsId,
252 );
253 const name = resourceName(parent, userCredsId);
254 const disabled = news.disabled === true;
255
256 let current = yield* getByName(output?.name ?? name);
257 let createdPassword = output?.securePassword;
258
259 if (current === undefined) {
260 const created = yield* firestore
261 .createProjectsDatabasesUserCreds({
262 parent,
263 userCredsId,
264 body: {},
265 })
266 .pipe(
267 Effect.catchTag("Conflict", () =>
268 listOnDatabase(parent).pipe(
269 Effect.map(
270 (creds) =>
271 creds.find(
272 (item) => lastSegment(item.name ?? "") === userCredsId,
273 ) ?? undefined,
274 ),
275 Effect.flatMap((found) =>
276 found !== undefined ? Effect.succeed(found) : getByName(name),
277 ),
278 ),
279 ),
280 );
281 current = created ?? undefined;
282 if (current?.securePassword !== undefined) {
283 createdPassword = current.securePassword;
284 }
285 }
286
287 if (current === undefined) {
288 return yield* new UserCredNotResolved({ name });
289 }
290
291 current = yield* syncEnabled(current.name ?? name, disabled, current);
292 return toAttrs(current, env.project, createdPassword);
293 }),
294
295 delete: Effect.fn(function* ({ output }) {
296 yield* firestore
297 .deleteProjectsDatabasesUserCreds({ name: output.name })
298 .pipe(Effect.catchTag("NotFound", () => Effect.void));
299 yield* waitUntilGone(output.name);
300 }),
301 });