| 1 | import * as firestore from "@distilled.cloud/gcp/firestore_v1"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import { bindGcpHost } from "../Host.ts"; |
| 4 | import { type BindingIam, grantFor } from "../HttpBinding.ts"; |
| 5 | import type { Database } from "./Database.ts"; |
| 6 | import type { DocumentSnapshot, ReadDatabaseClient } from "./ReadDatabase.ts"; |
| 7 | import { decodeFields, encodeFields, fieldPath } from "./Values.ts"; |
| 8 | import { |
| 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. |
| 15 | export const readDatabaseIam: BindingIam = { |
| 16 | role: "roles/datastore.viewer", |
| 17 | scopeByCondition: true, |
| 18 | }; |
| 19 | export const writeDatabaseIam: BindingIam = { |
| 20 | role: "roles/datastore.user", |
| 21 | scopeByCondition: true, |
| 22 | }; |
| 23 | export const readWriteDatabaseIam: BindingIam = { |
| 24 | role: "roles/datastore.user", |
| 25 | scopeByCondition: true, |
| 26 | }; |
| 27 | |
| 28 | const trimPath = (path: string) => path.replace(/^\/+|\/+$/g, ""); |
| 29 | |
| 30 | /** `"a/b/c"` → `{ parent: "a/b", last: "c" }`. */ |
| 31 | const 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 | |
| 39 | const 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 | */ |
| 53 | export 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. */ |
| 177 | export 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 | }); |