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 { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import type { Providers } from "../Providers.ts";
10import {
11 databaseNameOf,
12 lastSegment,
13 parseDatabaseName,
14 toResourceId,
15} from "./internal.ts";
16
17const DEFAULT_DATABASE = "(default)";
18const DEFAULT_COLLECTION = "_alchemy";
19
20export type DocumentFieldValue = {
21 /** UTF-8 string. */
22 stringValue?: string;
23 /** Integer encoded as a decimal string. */
24 integerValue?: string;
25 /** Boolean. */
26 booleanValue?: boolean;
27 /** IEEE 754 double. */
28 doubleValue?: number;
29 /** RFC3339 timestamp. */
30 timestampValue?: string;
31 /** Null sentinel (`NULL_VALUE`). */
32 nullValue?: string;
33};
34
35export type DocumentProps = {
36 /**
37 * Database id or `projects/{project}/databases/{database}`. Defaults
38 * to `"(default)"`. Immutable — changing it replaces the document.
39 * @default "(default)"
40 */
41 database?: string;
42 /**
43 * Collection id relative to `parent`. Immutable — changing it
44 * replaces the document.
45 * @default "_alchemy"
46 */
47 collectionId?: string;
48 /**
49 * Parent document path relative to the database (no leading slash),
50 * or empty for a root collection. Immutable — changing it replaces
51 * the document.
52 */
53 parentPath?: string;
54 /**
55 * Client-assigned document id. If omitted, a unique id is generated
56 * from the stack, stage, and logical id. Immutable — changing it
57 * replaces the document.
58 */
59 documentId?: string;
60 /**
61 * Document fields. Fields absent here are removed from the document.
62 */
63 fields?: Record<string, DocumentFieldValue>;
64};
65
66export type Document = Resource<
67 "GCP.Firestore.Document",
68 DocumentProps,
69 {
70 /** Full resource name `projects/{project}/databases/{database}/documents/{path}`. */
71 name: string;
72 /** Document id (last path segment). */
73 documentId: string;
74 /** Database resource name. */
75 database: string;
76 /** Database id. */
77 databaseId: string;
78 /** Collection id. */
79 collectionId: string;
80 /** Parent path relative to the database documents root. */
81 parentPath: string;
82 /** Path relative to the database documents root. */
83 documentPath: string;
84 /** Project id. */
85 project: string;
86 /** Document fields. */
87 fields: Record<string, DocumentFieldValue>;
88 /** RFC3339 creation timestamp. */
89 createTime: string | undefined;
90 /** RFC3339 last-update timestamp. */
91 updateTime: string | undefined;
92 },
93 never,
94 Providers
95>;
96
97/**
98 * A Cloud Firestore document.
99 *
100 * Documents have no labels field and Alchemy writes nothing but the given
101 * fields: `read` reports a document it finds without prior state as
102 * unowned (adopt it with `--adopt`). Changing
103 * `database`, `collectionId`, `parentPath`, or `documentId` replaces
104 * the document. User fields update in place via `documents.patch`.
105 *
106 * Runtime get/patch/delete of arbitrary documents still goes through
107 * {@link GetDocument} / {@link PatchDocument} / {@link DeleteDocument}.
108 *
109 * ### Creating a Document
110 * **Example:** Generated id in `_alchemy`
111 * ```typescript
112 * const doc = yield* GCP.Firestore.Document("Flag", {
113 * fields: { env: { stringValue: "test" } },
114 * });
115 * ```
116 *
117 * **Example:** Named document
118 * ```typescript
119 * const doc = yield* GCP.Firestore.Document("Alice", {
120 * database: "(default)",
121 * collectionId: "users",
122 * documentId: "alice",
123 * fields: { name: { stringValue: "Alice" } },
124 * });
125 * ```
126 *
127 * @resource
128 * @category Firestore
129 */
130export const Document = Resource<Document>("GCP.Firestore.Document");
131
132export class DocumentNotResolved extends Data.TaggedError(
133 "GCP.Firestore.DocumentNotResolved",
134)<{
135 name: string;
136}> {}
137
138const databaseIdOf = (value: string | undefined, project: string) => {
139 if (value === undefined || value.length === 0) return DEFAULT_DATABASE;
140 return parseDatabaseName(databaseNameOf(project, value)).databaseId;
141};
142
143const collectionOf = (value: string | undefined) =>
144 value && value.length > 0 ? value : DEFAULT_COLLECTION;
145
146const parentPathOf = (value: string | undefined) =>
147 (value ?? "").replace(/^\/+|\/+$/g, "");
148
149const documentsParent = (databaseName: string, parentPath: string) =>
150 parentPath.length === 0
151 ? `${databaseName}/documents`
152 : `${databaseName}/documents/${parentPath}`;
153
154const documentName = (
155 databaseName: string,
156 parentPath: string,
157 collectionId: string,
158 documentId: string,
159) =>
160 `${documentsParent(databaseName, parentPath)}/${collectionId}/${documentId}`;
161
162const parseDocumentName = (name: string) => {
163 const marker = "/documents/";
164 const at = name.indexOf(marker);
165 const databaseName = at >= 0 ? name.slice(0, at) : name;
166 const relative = at >= 0 ? name.slice(at + marker.length) : lastSegment(name);
167 const parts = relative.split("/").filter((part) => part.length > 0);
168 const documentId = parts[parts.length - 1] ?? "";
169 const collectionId =
170 parts.length >= 2 ? parts[parts.length - 2]! : DEFAULT_COLLECTION;
171 const parentParts = parts.slice(0, Math.max(0, parts.length - 2));
172 return {
173 ...parseDatabaseName(databaseName),
174 databaseName,
175 documentPath: relative,
176 documentId,
177 collectionId,
178 parentPath: parentParts.join("/"),
179 };
180};
181
182const fieldOf = (value: firestore.Value | undefined): DocumentFieldValue => ({
183 stringValue: value?.stringValue,
184 integerValue: value?.integerValue,
185 booleanValue: value?.booleanValue,
186 doubleValue: value?.doubleValue,
187 timestampValue: value?.timestampValue,
188 nullValue: value?.nullValue,
189});
190
191const userFields = (
192 fields: firestore.ValueMap | undefined,
193): Record<string, DocumentFieldValue> => {
194 const next: Record<string, DocumentFieldValue> = {};
195 for (const [key, value] of Object.entries(fields ?? {})) {
196 next[key] = fieldOf(value);
197 }
198 return next;
199};
200
201const desiredFields = (news: DocumentProps): firestore.ValueMap => ({
202 ...(news.fields ?? {}),
203});
204
205const jsonOf = (value: unknown) => JSON.stringify(value ?? null);
206
207const toAttrs = (current: firestore.Document, project: string) => {
208 const name = current.name ?? "";
209 const parsed = parseDocumentName(name);
210 return {
211 name,
212 documentId: parsed.documentId,
213 database: parsed.databaseName,
214 databaseId: parsed.databaseId,
215 collectionId: parsed.collectionId,
216 parentPath: parsed.parentPath,
217 documentPath: parsed.documentPath,
218 project: parsed.project || project,
219 fields: userFields(current.fields),
220 createTime: current.createTime,
221 updateTime: current.updateTime,
222 };
223};
224
225const getByName = (name: string) =>
226 name.length === 0
227 ? Effect.succeed(undefined)
228 : firestore
229 .getProjectsDatabasesDocuments({ name })
230 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
231
232export const DocumentProvider = () =>
233 Provider.succeed(Document, {
234 stables: [
235 "name",
236 "documentId",
237 "database",
238 "databaseId",
239 "collectionId",
240 "parentPath",
241 "documentPath",
242 "project",
243 "createTime",
244 ],
245
246 diff: Effect.fn(function* ({ news, olds, output }) {
247 if (!isResolved(news)) return undefined;
248 const previousDatabase = olds?.database ?? output?.databaseId;
249 const previousCollection = olds?.collectionId ?? output?.collectionId;
250 const previousParent = olds?.parentPath ?? output?.parentPath;
251 const previousId = olds?.documentId ?? output?.documentId;
252 if (
253 (previousDatabase !== undefined &&
254 news.database !== undefined &&
255 news.database !== previousDatabase) ||
256 (previousCollection !== undefined &&
257 news.collectionId !== undefined &&
258 news.collectionId !== previousCollection) ||
259 (previousParent !== undefined &&
260 news.parentPath !== undefined &&
261 parentPathOf(news.parentPath) !== parentPathOf(previousParent)) ||
262 (previousId !== undefined &&
263 news.documentId !== undefined &&
264 news.documentId !== previousId)
265 ) {
266 return { action: "replace" as const, deleteFirst: true };
267 }
268 return undefined;
269 }),
270
271 read: Effect.fn(function* ({ id, olds, output }) {
272 const env = yield* GcpEnvironment.current;
273 const databaseId = databaseIdOf(
274 olds?.database ?? output?.database ?? output?.databaseId,
275 env.project,
276 );
277 const databaseName = databaseNameOf(env.project, databaseId);
278 const collectionId = collectionOf(
279 olds?.collectionId ?? output?.collectionId,
280 );
281 const parentPath = parentPathOf(olds?.parentPath ?? output?.parentPath);
282 const documentId = yield* toResourceId(
283 id,
284 olds?.documentId,
285 output?.documentId,
286 );
287 const name =
288 output?.name ??
289 documentName(databaseName, parentPath, collectionId, documentId);
290 const existing = yield* getByName(name);
291 if (existing === undefined) return undefined;
292 const attrs = toAttrs(existing, env.project);
293 // No labels field: without prior state it may not be ours.
294 return output !== undefined ? attrs : Unowned(attrs);
295 }),
296
297 reconcile: Effect.fn(function* ({ id, news, output }) {
298 const env = yield* GcpEnvironment.current;
299 const databaseId = databaseIdOf(
300 news.database ?? output?.database ?? output?.databaseId,
301 env.project,
302 );
303 const databaseName = databaseNameOf(env.project, databaseId);
304 const collectionId = collectionOf(news.collectionId);
305 const parentPath = parentPathOf(news.parentPath);
306 const documentId = yield* toResourceId(
307 id,
308 news.documentId,
309 output?.documentId,
310 );
311 const parent = documentsParent(databaseName, parentPath);
312 const name = documentName(
313 databaseName,
314 parentPath,
315 collectionId,
316 documentId,
317 );
318 const fields = desiredFields(news);
319
320 let current = yield* getByName(output?.name ?? name);
321
322 if (current === undefined) {
323 const created = yield* firestore
324 .createDocumentProjectsDatabasesDocuments({
325 parent,
326 collectionId,
327 documentId,
328 body: { fields },
329 })
330 .pipe(Effect.catchTag("Conflict", () => getByName(name)));
331 current = created ?? undefined;
332 }
333
334 if (current === undefined) {
335 return yield* new DocumentNotResolved({ name });
336 }
337
338 if (jsonOf(current.fields) !== jsonOf(fields)) {
339 current = yield* firestore.patchProjectsDatabasesDocuments({
340 name: current.name ?? name,
341 // Masking observed fields too removes the ones no longer desired.
342 "updateMask.fieldPaths": Object.keys({
343 ...current.fields,
344 ...fields,
345 }),
346 body: { fields },
347 });
348 }
349
350 return toAttrs(current, env.project);
351 }),
352
353 delete: Effect.fn(function* ({ output }) {
354 if (!output.name) return;
355 yield* firestore
356 .deleteProjectsDatabasesDocuments({ name: output.name })
357 .pipe(Effect.catchTag("NotFound", () => Effect.void));
358 }),
359 });