1import * as firestore from "@distilled.cloud/gcp/firestore_v1";
2import * as Effect from "effect/Effect";
3import { bindGcpHost } from "../Host.ts";
4import { type BindingIam, grantFor } from "../HttpBinding.ts";
5import type { Database } from "./Database.ts";
6import type { DocumentSnapshot, ReadDatabaseClient } from "./ReadDatabase.ts";
7import { decodeFields, encodeFields, fieldPath } from "./Values.ts";
8import {
9 DocumentAlreadyExists,
10 type WriteDatabaseClient,
11} from "./WriteDatabase.ts";
12
13// Firestore databases have no resource-level IAM policy: each grant is on
14// the project under an IAM Condition naming the bound database.
15export const readDatabaseIam: BindingIam = {
16 role: "roles/datastore.viewer",
17 scopeByCondition: true,
18};
19export const writeDatabaseIam: BindingIam = {
20 role: "roles/datastore.user",
21 scopeByCondition: true,
22};
23export const readWriteDatabaseIam: BindingIam = {
24 role: "roles/datastore.user",
25 scopeByCondition: true,
26};
27
28const trimPath = (path: string) => path.replace(/^\/+|\/+$/g, "");
29
30/** `"a/b/c"` → `{ parent: "a/b", last: "c" }`. */
31const splitLast = (path: string) => {
32 const trimmed = trimPath(path);
33 const at = trimmed.lastIndexOf("/");
34 return at < 0
35 ? { parent: "", last: trimmed }
36 : { parent: trimmed.slice(0, at), last: trimmed.slice(at + 1) };
37};
38
39const snapshot = (document: firestore.Document): DocumentSnapshot => ({
40 name: document.name ?? "",
41 fields: decodeFields(document.fields),
42 createTime: document.createTime,
43 updateTime: document.updateTime,
44});
45
46/**
47 * Shared HTTP scaffolding for the Firestore Read/Write/ReadWrite bindings:
48 * resolves the distilled operations once at Layer construction and grants
49 * the level's role at deploy time.
50 *
51 * NOT exported from `index.ts`.
52 */
53export const makeFirestoreDatabaseHelpers = Effect.gen(function* () {
54 const getDocument = yield* firestore.getProjectsDatabasesDocuments;
55 const listDocuments = yield* firestore.listProjectsDatabasesDocuments;
56 const runQuery = yield* firestore.runQueryProjectsDatabasesDocuments;
57 const patchDocument = yield* firestore.patchProjectsDatabasesDocuments;
58 const createDocument =
59 yield* firestore.createDocumentProjectsDatabasesDocuments;
60 const deleteDocument = yield* firestore.deleteProjectsDatabasesDocuments;
61
62 const makeRead = (
63 databaseName: Effect.Effect<string>,
64 ): ReadDatabaseClient => {
65 const documentsRoot = Effect.map(databaseName, (db) => `${db}/documents`);
66 const parentOf = (path: string) =>
67 Effect.map(documentsRoot, (root) =>
68 path.length > 0 ? `${root}/${path}` : root,
69 );
70 return {
71 get: (path) =>
72 parentOf(trimPath(path)).pipe(
73 Effect.flatMap((name) => getDocument({ name })),
74 Effect.map(snapshot),
75 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
76 ),
77 list: (collection, options) => {
78 const { parent, last } = splitLast(collection);
79 return parentOf(parent).pipe(
80 Effect.flatMap((parent) =>
81 listDocuments({
82 parent,
83 collectionId: last,
84 pageSize: options?.pageSize,
85 pageToken: options?.pageToken,
86 }),
87 ),
88 Effect.map((page) => ({
89 documents: (page.documents ?? []).map(snapshot),
90 nextPageToken: page.nextPageToken || undefined,
91 })),
92 );
93 },
94 query: (structuredQuery) =>
95 documentsRoot.pipe(
96 Effect.flatMap((parent) =>
97 runQuery({ parent, body: { structuredQuery } }),
98 ),
99 // The REST endpoint streams a JSON array of RunQueryResponse
100 // messages; the generated output type models a single message.
101 Effect.map((response): ReadonlyArray<firestore.RunQueryResponse> =>
102 Array.isArray(response) ? response : [response],
103 ),
104 Effect.map((responses) =>
105 responses.flatMap((r) =>
106 r.document === undefined ? [] : [snapshot(r.document)],
107 ),
108 ),
109 ),
110 };
111 };
112
113 const makeWrite = (
114 databaseName: Effect.Effect<string>,
115 ): WriteDatabaseClient => {
116 const documentName = (path: string) =>
117 Effect.map(databaseName, (db) => `${db}/documents/${trimPath(path)}`);
118 return {
119 set: (path, fields) =>
120 documentName(path).pipe(
121 Effect.flatMap((name) =>
122 patchDocument({ name, body: { fields: encodeFields(fields) } }),
123 ),
124 Effect.map(snapshot),
125 ),
126 update: (path, fields) =>
127 documentName(path).pipe(
128 Effect.flatMap((name) =>
129 patchDocument({
130 name,
131 "updateMask.fieldPaths": Object.keys(fields).map(fieldPath),
132 "currentDocument.exists": true,
133 body: { fields: encodeFields(fields) },
134 }),
135 ),
136 Effect.map(snapshot),
137 ),
138 delete: (path) =>
139 documentName(path).pipe(
140 Effect.flatMap((name) => deleteDocument({ name })),
141 Effect.asVoid,
142 Effect.catchTag("NotFound", () => Effect.void),
143 ),
144 create: (path, fields) => {
145 const { parent, last } = splitLast(path);
146 const collection = splitLast(parent);
147 return databaseName.pipe(
148 Effect.flatMap((db) =>
149 createDocument({
150 parent:
151 collection.parent.length > 0
152 ? `${db}/documents/${collection.parent}`
153 : `${db}/documents`,
154 collectionId: collection.last,
155 documentId: last,
156 body: { fields: encodeFields(fields) },
157 }),
158 ),
159 Effect.map(snapshot),
160 Effect.catchTag("Conflict", (error) =>
161 Effect.fail(
162 new DocumentAlreadyExists({
163 path: trimPath(path),
164 message: error.message,
165 }),
166 ),
167 ),
168 );
169 },
170 };
171 };
172
173 return { makeRead, makeWrite };
174});
175
176/** Build a database binding that grants `iam` and returns `makeClient`'s client. */
177export const makeFirestoreDatabaseBinding = <Client>(options: {
178 tag: string;
179 iam: BindingIam;
180 makeClient: (
181 helpers: Effect.Success<typeof makeFirestoreDatabaseHelpers>,
182 databaseName: Effect.Effect<string>,
183 ) => Client;
184}) =>
185 Effect.gen(function* () {
186 const helpers = yield* makeFirestoreDatabaseHelpers;
187 return Effect.fn(function* (database: Database) {
188 yield* bindGcpHost({
189 tag: options.tag,
190 resource: database,
191 iam: [grantFor(options.iam, database.name)],
192 });
193 const databaseName = yield* database.name;
194 return options.makeClient(helpers, databaseName);
195 });
196 });