@visiolab/effect-firebase-client 0.0.0-stage → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +70 -2
- package/dist/_virtual/_rolldown/runtime.mjs +12 -0
- package/dist/app/App.d.mts +117 -0
- package/dist/app/App.mjs +149 -0
- package/dist/app/App.mjs.map +1 -0
- package/dist/app/index.d.mts +2 -0
- package/dist/app/index.mjs +1 -0
- package/dist/app/internal/app.mjs +20 -0
- package/dist/app/internal/app.mjs.map +1 -0
- package/dist/firestore/ClientDocumentSnapshot.d.mts +106 -0
- package/dist/firestore/ClientDocumentSnapshot.mjs +86 -0
- package/dist/firestore/ClientDocumentSnapshot.mjs.map +1 -0
- package/dist/firestore/ClientFirestore.d.mts +331 -0
- package/dist/firestore/ClientFirestore.mjs +146 -0
- package/dist/firestore/ClientFirestore.mjs.map +1 -0
- package/dist/firestore/ClientFirestoreValue.d.mts +61 -0
- package/dist/firestore/ClientFirestoreValue.mjs +62 -0
- package/dist/firestore/ClientFirestoreValue.mjs.map +1 -0
- package/dist/firestore/ClientQuerySnapshot.d.mts +52 -0
- package/dist/firestore/ClientQuerySnapshot.mjs +58 -0
- package/dist/firestore/ClientQuerySnapshot.mjs.map +1 -0
- package/dist/firestore/index.d.mts +5 -0
- package/dist/firestore/index.mjs +5 -0
- package/dist/firestore/internal/firestore.mjs +158 -0
- package/dist/firestore/internal/firestore.mjs.map +1 -0
- package/dist/firestore/internal/firestoreValue.mjs +43 -0
- package/dist/firestore/internal/firestoreValue.mjs.map +1 -0
- package/dist/firestore/internal/snapshotMetadata.mjs +15 -0
- package/dist/firestore/internal/snapshotMetadata.mjs.map +1 -0
- package/dist/functions/CallableClient.d.mts +139 -0
- package/dist/functions/CallableClient.mjs +215 -0
- package/dist/functions/CallableClient.mjs.map +1 -0
- package/dist/functions/ClientFunctions.d.mts +112 -0
- package/dist/functions/ClientFunctions.mjs +105 -0
- package/dist/functions/ClientFunctions.mjs.map +1 -0
- package/dist/functions/index.d.mts +3 -0
- package/dist/functions/index.mjs +3 -0
- package/package.json +63 -3
- package/src/app/App.ts +163 -0
- package/src/app/index.ts +7 -0
- package/src/app/internal/app.ts +35 -0
- package/src/firestore/ClientDocumentSnapshot.ts +156 -0
- package/src/firestore/ClientFirestore.ts +410 -0
- package/src/firestore/ClientFirestoreValue.ts +94 -0
- package/src/firestore/ClientQuerySnapshot.ts +93 -0
- package/src/firestore/index.ts +30 -0
- package/src/firestore/internal/firestore.ts +264 -0
- package/src/firestore/internal/firestoreValue.ts +58 -0
- package/src/firestore/internal/snapshotMetadata.ts +14 -0
- package/src/functions/CallableClient.ts +296 -0
- package/src/functions/ClientFunctions.ts +149 -0
- package/src/functions/index.ts +15 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { Effect, Result } from "effect";
|
|
2
|
+
import * as Firestore from "@visiolab/effect-firebase/firestore/Firestore";
|
|
3
|
+
import { FirebaseError } from "firebase/app";
|
|
4
|
+
import * as Client from "firebase/firestore";
|
|
5
|
+
import * as App from "../../app/App.ts";
|
|
6
|
+
import type * as ClientFirestore from "../ClientFirestore.ts";
|
|
7
|
+
import * as firestoreValue from "./firestoreValue.ts";
|
|
8
|
+
import * as snapshotMetadata from "./snapshotMetadata.ts";
|
|
9
|
+
|
|
10
|
+
/** @internal */
|
|
11
|
+
export const TypeId = "~@visiolab/effect-firebase-client/firestore/ClientFirestore";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Opens the database the options name.
|
|
15
|
+
*
|
|
16
|
+
* Settings can only be handed over through `initializeFirestore`, which refuses
|
|
17
|
+
* a database already opened, so they are only passed along when there are
|
|
18
|
+
* some: asking for the default database twice keeps working, and asking for it
|
|
19
|
+
* twice with settings is the mistake it reports.
|
|
20
|
+
*
|
|
21
|
+
* The emulator is connected after, which the SDK takes as often as it is asked
|
|
22
|
+
* for the same one, but only before the database is first used.
|
|
23
|
+
*/
|
|
24
|
+
const open = (app: App.App, options: ClientFirestore.Options | undefined): Client.Firestore => {
|
|
25
|
+
const databaseId = options?.databaseId;
|
|
26
|
+
const firestore =
|
|
27
|
+
options?.localCache === undefined
|
|
28
|
+
? databaseId === undefined
|
|
29
|
+
? Client.getFirestore(app)
|
|
30
|
+
: Client.getFirestore(app, databaseId)
|
|
31
|
+
: Client.initializeFirestore(app, { localCache: options.localCache }, databaseId);
|
|
32
|
+
if (options?.emulator !== undefined) {
|
|
33
|
+
Client.connectFirestoreEmulator(firestore, options.emulator.host, options.emulator.port);
|
|
34
|
+
}
|
|
35
|
+
return firestore;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Opens the database the options name on the {@link App.App} in context.
|
|
40
|
+
*
|
|
41
|
+
* The SDK memoizes a database on the app itself, and deleting the app
|
|
42
|
+
* terminates it, so there is nothing to release here.
|
|
43
|
+
*
|
|
44
|
+
* @internal
|
|
45
|
+
*/
|
|
46
|
+
export const openDatabase = Effect.fnUntraced(function* (options?: ClientFirestore.Options) {
|
|
47
|
+
const app = yield* App.App;
|
|
48
|
+
return open(app, options);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const firestoreError = (message: string) => (cause: unknown) =>
|
|
52
|
+
new Firestore.FirestoreError({
|
|
53
|
+
message,
|
|
54
|
+
// The client SDK names every failure it reports, such as
|
|
55
|
+
// `permission-denied`, whether it raised it itself or the database did.
|
|
56
|
+
code: cause instanceof FirebaseError ? cause.code : undefined,
|
|
57
|
+
cause,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/** Runs one call to the SDK, turning what it throws or rejects with into a `FirestoreError`. */
|
|
61
|
+
const call = <A>(
|
|
62
|
+
message: string,
|
|
63
|
+
f: () => PromiseLike<A> | A,
|
|
64
|
+
): Effect.Effect<A, Firestore.FirestoreError> =>
|
|
65
|
+
Effect.tryPromise({
|
|
66
|
+
try: () => Promise.resolve(f()),
|
|
67
|
+
catch: firestoreError(message),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Reads a document's data as Firestore Values, with the server timestamps it
|
|
72
|
+
* is still waiting on estimated, rather than left `null` where a timestamp is
|
|
73
|
+
* expected.
|
|
74
|
+
*
|
|
75
|
+
* A document holding a value outside the Firestore Value union, such as a
|
|
76
|
+
* vector, fails to read: it could only ever be decoded as an untyped object.
|
|
77
|
+
* `data()` hands back `undefined` for a document that is not there, which is
|
|
78
|
+
* kept as it is.
|
|
79
|
+
*/
|
|
80
|
+
const dataOf = (
|
|
81
|
+
path: string,
|
|
82
|
+
snapshot: Client.DocumentSnapshot,
|
|
83
|
+
): Result.Result<Firestore.DocumentData | undefined, Firestore.FirestoreError> =>
|
|
84
|
+
firestoreValue.readDocument(
|
|
85
|
+
path,
|
|
86
|
+
snapshot.data({ serverTimestamps: "estimate" }),
|
|
87
|
+
) as Result.Result<Firestore.DocumentData | undefined, Firestore.FirestoreError>;
|
|
88
|
+
|
|
89
|
+
/** What the client knows about a read beyond the portable snapshot. */
|
|
90
|
+
type Extra = ReturnType<typeof snapshotMetadata.extraOf>;
|
|
91
|
+
|
|
92
|
+
/** A read of one document, with the metadata the SDK reported for it. */
|
|
93
|
+
const documentReadOf = (
|
|
94
|
+
snapshot: Client.DocumentSnapshot,
|
|
95
|
+
): Result.Result<Firestore.DocumentRead<Extra>, Firestore.FirestoreError> =>
|
|
96
|
+
Result.map(dataOf(snapshot.ref.path, snapshot), (data) => ({
|
|
97
|
+
data,
|
|
98
|
+
extra: snapshotMetadata.extraOf(snapshot.metadata),
|
|
99
|
+
}));
|
|
100
|
+
|
|
101
|
+
/** A read of many documents, each and as a whole with the metadata the SDK reported. */
|
|
102
|
+
const queryReadOf = (
|
|
103
|
+
query: Client.QuerySnapshot,
|
|
104
|
+
): Result.Result<Firestore.QueryRead<Extra>, Firestore.FirestoreError> =>
|
|
105
|
+
Result.map(
|
|
106
|
+
Result.all(
|
|
107
|
+
query.docs.map((snapshot) =>
|
|
108
|
+
Result.map(dataOf(snapshot.ref.path, snapshot), (data) => ({
|
|
109
|
+
path: snapshot.ref.path,
|
|
110
|
+
// A query only hands back documents that are there.
|
|
111
|
+
data: data as Firestore.DocumentData,
|
|
112
|
+
extra: snapshotMetadata.extraOf(snapshot.metadata),
|
|
113
|
+
})),
|
|
114
|
+
),
|
|
115
|
+
),
|
|
116
|
+
(docs) => ({ docs, extra: snapshotMetadata.extraOf(query.metadata) }),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
/** The client SDK query `selector` names, and what it is called in a failure. */
|
|
120
|
+
const queryOf = (
|
|
121
|
+
firestore: Client.Firestore,
|
|
122
|
+
selector: Firestore.CollectionSelector,
|
|
123
|
+
): { readonly query: Client.Query; readonly name: string } =>
|
|
124
|
+
selector._tag === "CollectionGroup"
|
|
125
|
+
? {
|
|
126
|
+
query: Client.collectionGroup(firestore, selector.id),
|
|
127
|
+
name: `the collection group "${selector.id}"`,
|
|
128
|
+
}
|
|
129
|
+
: {
|
|
130
|
+
query: Client.collection(firestore, selector.path),
|
|
131
|
+
name: `the collection "${selector.path}"`,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** The SDK's own reads, for one {@link ClientFirestore.ReadSource}. */
|
|
135
|
+
interface Reads {
|
|
136
|
+
readonly getDoc: (ref: Client.DocumentReference) => Promise<Client.DocumentSnapshot>;
|
|
137
|
+
readonly getDocs: (query: Client.Query) => Promise<Client.QuerySnapshot>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const reads: Record<ClientFirestore.ReadSource, Reads> = {
|
|
141
|
+
default: { getDoc: Client.getDoc, getDocs: Client.getDocs },
|
|
142
|
+
server: { getDoc: Client.getDocFromServer, getDocs: Client.getDocsFromServer },
|
|
143
|
+
cache: { getDoc: Client.getDocFromCache, getDocs: Client.getDocsFromCache },
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/** The SDK's reads from where `options` names, `"default"` where it names none. */
|
|
147
|
+
const readsFrom = (options: ClientFirestore.ReadOptions | undefined): Reads =>
|
|
148
|
+
reads[options?.source ?? "default"];
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The SDK's listen options for `options`, leaving out what is not given rather
|
|
152
|
+
* than handing the SDK an `undefined` in its place.
|
|
153
|
+
*/
|
|
154
|
+
const snapshotListenOptions = (
|
|
155
|
+
options: ClientFirestore.ListenOptions | undefined,
|
|
156
|
+
): Client.SnapshotListenOptions => ({
|
|
157
|
+
...(options?.includeMetadataChanges === undefined
|
|
158
|
+
? {}
|
|
159
|
+
: { includeMetadataChanges: options.includeMetadataChanges }),
|
|
160
|
+
...(options?.source === undefined ? {} : { source: options.source }),
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The service over a database already opened.
|
|
165
|
+
*
|
|
166
|
+
* @internal
|
|
167
|
+
*/
|
|
168
|
+
export const fromDatabase = (
|
|
169
|
+
firestore: Client.Firestore,
|
|
170
|
+
): ClientFirestore.ClientFirestore & Firestore.Firestore => {
|
|
171
|
+
const docOf = (path: string) => Client.doc(firestore, path);
|
|
172
|
+
const toClient = (data: Firestore.DocumentData) =>
|
|
173
|
+
firestoreValue.toClient(data, firestore) as Client.DocumentData;
|
|
174
|
+
|
|
175
|
+
const made = Firestore.make({
|
|
176
|
+
getDoc: (path, options: ClientFirestore.ReadOptions | undefined) =>
|
|
177
|
+
Effect.flatMap(
|
|
178
|
+
call(`Failed to read the document "${path}"`, () => readsFrom(options).getDoc(docOf(path))),
|
|
179
|
+
(snapshot) => Effect.fromResult(documentReadOf(snapshot)),
|
|
180
|
+
),
|
|
181
|
+
|
|
182
|
+
getDocs: (selector, options: ClientFirestore.ReadOptions | undefined) => {
|
|
183
|
+
const { query, name } = queryOf(firestore, selector);
|
|
184
|
+
return Effect.flatMap(
|
|
185
|
+
call(`Failed to read ${name}`, () => readsFrom(options).getDocs(query)),
|
|
186
|
+
(snapshot) => Effect.fromResult(queryReadOf(snapshot)),
|
|
187
|
+
);
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
setDoc: (path, data) =>
|
|
191
|
+
call(`Failed to write the document "${path}"`, () =>
|
|
192
|
+
Client.setDoc(docOf(path), toClient(data)),
|
|
193
|
+
),
|
|
194
|
+
|
|
195
|
+
updateDoc: (path, data) =>
|
|
196
|
+
call(`Failed to update the document "${path}"`, () =>
|
|
197
|
+
Client.updateDoc(docOf(path), toClient(data)),
|
|
198
|
+
),
|
|
199
|
+
|
|
200
|
+
addDoc: (path, data) =>
|
|
201
|
+
Effect.map(
|
|
202
|
+
call(`Failed to add a document to "${path}"`, () =>
|
|
203
|
+
Client.addDoc(Client.collection(firestore, path), toClient(data)),
|
|
204
|
+
),
|
|
205
|
+
(added) => added.id,
|
|
206
|
+
),
|
|
207
|
+
|
|
208
|
+
// The client SDK hands a listener the documents as this client last wrote
|
|
209
|
+
// them, before the database has taken the writes, so each write is seen
|
|
210
|
+
// straight away rather than once it is acknowledged.
|
|
211
|
+
streamDoc: (path, options: ClientFirestore.ListenOptions | undefined) =>
|
|
212
|
+
Firestore.streamFromListener({
|
|
213
|
+
subscribe: (onNext, onError) =>
|
|
214
|
+
Client.onSnapshot(docOf(path), snapshotListenOptions(options), onNext, onError),
|
|
215
|
+
onError: firestoreError(`Failed to listen to the document "${path}"`),
|
|
216
|
+
map: documentReadOf,
|
|
217
|
+
}),
|
|
218
|
+
|
|
219
|
+
streamDocs: (selector, options: ClientFirestore.ListenOptions | undefined) => {
|
|
220
|
+
const { query, name } = queryOf(firestore, selector);
|
|
221
|
+
return Firestore.streamFromListener({
|
|
222
|
+
subscribe: (onNext, onError) =>
|
|
223
|
+
Client.onSnapshot(query, snapshotListenOptions(options), onNext, onError),
|
|
224
|
+
onError: firestoreError(`Failed to listen to ${name}`),
|
|
225
|
+
map: queryReadOf,
|
|
226
|
+
});
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
runTransaction: Firestore.transactionFromCallback({
|
|
230
|
+
run: (callback, options) => Client.runTransaction(firestore, callback, options),
|
|
231
|
+
operations: (tx: Client.Transaction) => ({
|
|
232
|
+
// A transaction reads from the server alone and never holds writes of
|
|
233
|
+
// its own that are pending, so its metadata says nothing and is left
|
|
234
|
+
// out: what it hands back is the portable snapshot.
|
|
235
|
+
getDoc: (path) =>
|
|
236
|
+
Effect.flatMap(
|
|
237
|
+
call(`Failed to read the document "${path}"`, () => tx.get(docOf(path))),
|
|
238
|
+
(snapshot) =>
|
|
239
|
+
Effect.fromResult(Result.map(dataOf(path, snapshot), (data) => ({ data }))),
|
|
240
|
+
),
|
|
241
|
+
// Writes into a transaction are queued rather than sent, so these return
|
|
242
|
+
// straight away - or throw, where the transaction refuses them.
|
|
243
|
+
setDoc: (path, data) =>
|
|
244
|
+
call(`Failed to write the document "${path}"`, () => {
|
|
245
|
+
tx.set(docOf(path), toClient(data));
|
|
246
|
+
}),
|
|
247
|
+
updateDoc: (path, data) =>
|
|
248
|
+
call(`Failed to update the document "${path}"`, () => {
|
|
249
|
+
tx.update(docOf(path), toClient(data));
|
|
250
|
+
}),
|
|
251
|
+
}),
|
|
252
|
+
onError: firestoreError("Failed to run a transaction"),
|
|
253
|
+
}),
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// `make` infers the read source and listen options its hooks are handed and
|
|
257
|
+
// the metadata they read with each snapshot, so the client's interface is
|
|
258
|
+
// checked against what it built here.
|
|
259
|
+
const service: ClientFirestore.ClientFirestore & Firestore.Firestore = Object.assign(made, {
|
|
260
|
+
[TypeId]: TypeId as typeof TypeId,
|
|
261
|
+
instance: firestore,
|
|
262
|
+
});
|
|
263
|
+
return service;
|
|
264
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Result } from "effect";
|
|
2
|
+
import * as DocumentPath from "@visiolab/effect-firebase/firestore/DocumentPath";
|
|
3
|
+
import type { FirestoreError } from "@visiolab/effect-firebase/firestore/Firestore";
|
|
4
|
+
import * as FirestoreValue from "@visiolab/effect-firebase/firestore/FirestoreValue";
|
|
5
|
+
import * as GeoPoint from "@visiolab/effect-firebase/firestore/GeoPoint";
|
|
6
|
+
import * as Timestamp from "@visiolab/effect-firebase/firestore/Timestamp";
|
|
7
|
+
import * as Client from "firebase/firestore";
|
|
8
|
+
|
|
9
|
+
/** How the client SDK writes each kind of Firestore Value into `firestore`. */
|
|
10
|
+
const writer = (firestore: Client.Firestore): FirestoreValue.Writer => ({
|
|
11
|
+
// The client SDK refuses a raw `Uint8Array`, as a custom object.
|
|
12
|
+
bytes: (bytes) => Client.Bytes.fromUint8Array(bytes),
|
|
13
|
+
timestamp: (ts) => new Client.Timestamp(ts.seconds, ts.nanoseconds),
|
|
14
|
+
geoPoint: (point) => new Client.GeoPoint(point.latitude, point.longitude),
|
|
15
|
+
documentPath: (ref) => Client.doc(firestore, ref.path),
|
|
16
|
+
serverTimestamp: () => Client.serverTimestamp(),
|
|
17
|
+
increment: (increment) => Client.increment(increment.by),
|
|
18
|
+
deleteField: () => Client.deleteField(),
|
|
19
|
+
arrayUnion: (union, walk) => Client.arrayUnion(...union.elements.map(walk)),
|
|
20
|
+
arrayRemove: (remove, walk) => Client.arrayRemove(...remove.elements.map(walk)),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** @internal */
|
|
24
|
+
export const toClient = (
|
|
25
|
+
value: FirestoreValue.FirestoreValue,
|
|
26
|
+
firestore: Client.Firestore,
|
|
27
|
+
): unknown => FirestoreValue.toSdk(value, writer(firestore));
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* How the client SDK's own values read back as Firestore Values. Its
|
|
31
|
+
* sentinels are not read back: a read never hands one back, and the names it
|
|
32
|
+
* keeps them by are internal to it.
|
|
33
|
+
*/
|
|
34
|
+
const reader: FirestoreValue.Reader = (u) => {
|
|
35
|
+
if (u instanceof Client.Timestamp) return Timestamp.makeUnsafe(u.seconds, u.nanoseconds);
|
|
36
|
+
if (u instanceof Client.GeoPoint) return GeoPoint.makeUnsafe(u.latitude, u.longitude);
|
|
37
|
+
if (u instanceof Client.DocumentReference) return DocumentPath.makeUnsafe(u.path);
|
|
38
|
+
if (u instanceof Client.Bytes) return u.toUint8Array();
|
|
39
|
+
return undefined;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** @internal */
|
|
43
|
+
export const fromClient = (
|
|
44
|
+
value: unknown,
|
|
45
|
+
): Result.Result<FirestoreValue.FirestoreValue, FirestoreError> =>
|
|
46
|
+
FirestoreValue.fromSdk(value, reader);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What the client SDK read for the document at `path`, as Firestore Values,
|
|
50
|
+
* or `undefined` for a document that is not there.
|
|
51
|
+
*
|
|
52
|
+
* @internal
|
|
53
|
+
*/
|
|
54
|
+
export const readDocument = (
|
|
55
|
+
path: string,
|
|
56
|
+
data: Client.DocumentData | undefined,
|
|
57
|
+
): Result.Result<FirestoreValue.FirestoreValue | undefined, FirestoreError> =>
|
|
58
|
+
FirestoreValue.readDocument(path, data, reader);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type * as ClientDocumentSnapshot from "../ClientDocumentSnapshot.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The extra a client snapshot is built with: its metadata, copied out of the
|
|
5
|
+
* SDK's own object so that the snapshot holds, and prints, only these two
|
|
6
|
+
* fields.
|
|
7
|
+
*
|
|
8
|
+
* @internal
|
|
9
|
+
*/
|
|
10
|
+
export const extraOf = (
|
|
11
|
+
metadata: ClientDocumentSnapshot.SnapshotMetadata,
|
|
12
|
+
): { readonly metadata: ClientDocumentSnapshot.SnapshotMetadata } => ({
|
|
13
|
+
metadata: { fromCache: metadata.fromCache, hasPendingWrites: metadata.hasPendingWrites },
|
|
14
|
+
});
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Callable Clients: call the Callables a server serves, with the Payload, the
|
|
3
|
+
* success value and the errors typed by their contract.
|
|
4
|
+
*
|
|
5
|
+
* A client calls through the {@link ClientFunctions.ClientFunctions} in
|
|
6
|
+
* context, so the region and the emulator are set once, where that is
|
|
7
|
+
* provided.
|
|
8
|
+
*
|
|
9
|
+
* @example Calling a Callable
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { Effect, Layer, Schema } from "effect";
|
|
12
|
+
* import { Callable } from "@visiolab/effect-firebase/functions";
|
|
13
|
+
* import { App } from "@visiolab/effect-firebase-client/app";
|
|
14
|
+
* import { CallableClient, ClientFunctions } from "@visiolab/effect-firebase-client/functions";
|
|
15
|
+
*
|
|
16
|
+
* const Greet = Callable.make("greet", {
|
|
17
|
+
* payload: { name: Schema.String },
|
|
18
|
+
* success: Schema.String,
|
|
19
|
+
* });
|
|
20
|
+
*
|
|
21
|
+
* const program = Effect.gen(function* () {
|
|
22
|
+
* const { greet } = yield* CallableClient.make(Greet);
|
|
23
|
+
* const message = yield* greet({ name: "Ada" });
|
|
24
|
+
* yield* Effect.log(message);
|
|
25
|
+
* });
|
|
26
|
+
*
|
|
27
|
+
* const FunctionsLive = ClientFunctions.layer().pipe(
|
|
28
|
+
* Layer.provide(App.layer({ apiKey: "<api key>", projectId: "my-project", appId: "<app id>" })),
|
|
29
|
+
* );
|
|
30
|
+
*
|
|
31
|
+
* program.pipe(Effect.provide(FunctionsLive));
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* @stability unstable
|
|
35
|
+
* @since 0.3.0
|
|
36
|
+
*/
|
|
37
|
+
import { Effect, Schema } from "effect";
|
|
38
|
+
import * as Callable from "@visiolab/effect-firebase/functions/Callable";
|
|
39
|
+
import * as CallableError from "@visiolab/effect-firebase/functions/CallableError";
|
|
40
|
+
import * as FunctionGroup from "@visiolab/effect-firebase/functions/FunctionGroup";
|
|
41
|
+
import * as Sdk from "firebase/functions";
|
|
42
|
+
import * as ClientFunctions from "./ClientFunctions.ts";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A client of one Callable: a function from its Payload to an Effect of its
|
|
46
|
+
* success value, failing with its declared errors or a `CallableError`.
|
|
47
|
+
*
|
|
48
|
+
* @stability unstable
|
|
49
|
+
* @category models
|
|
50
|
+
* @since 0.3.0
|
|
51
|
+
*/
|
|
52
|
+
export type Method<C extends Callable.Any> = (
|
|
53
|
+
payload: Callable.Payload<C>,
|
|
54
|
+
) => Effect.Effect<Callable.Success<C>, Callable.Error<C> | CallableError.CallableError>;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What {@link make} returns: one {@link Method} per Callable, keyed by the
|
|
58
|
+
* Callable's name. For a Function Group, one per Callable member.
|
|
59
|
+
*
|
|
60
|
+
* @stability unstable
|
|
61
|
+
* @category models
|
|
62
|
+
* @since 0.3.0
|
|
63
|
+
*/
|
|
64
|
+
export type Client<T extends Callable.Any | FunctionGroup.Any> = {
|
|
65
|
+
readonly [C in Members<T> as C["name"]]: Method<C>;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The Callables a client of `T` calls: the Callable members of a Function
|
|
70
|
+
* Group, or the Callable itself. Members of other kinds are left out.
|
|
71
|
+
*
|
|
72
|
+
* @stability unstable
|
|
73
|
+
* @category type-level
|
|
74
|
+
* @since 0.3.0
|
|
75
|
+
*/
|
|
76
|
+
export type Members<T extends Callable.Any | FunctionGroup.Any> = T extends FunctionGroup.Any
|
|
77
|
+
? FunctionGroup.Members<T, Callable.Any>
|
|
78
|
+
: T;
|
|
79
|
+
|
|
80
|
+
/** The ` [<status>]` the SDK appends to the message of an error built from an answer. */
|
|
81
|
+
const statusSuffix = / \[(\d+)\]$/;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The messages of the SDK's errors for an answer it can't read: not a JSON
|
|
85
|
+
* object, or one without `data` or `error`.
|
|
86
|
+
*/
|
|
87
|
+
const malformedAnswer = /Response is (?:not valid JSON object|missing data field)\.$/;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The client function of `callable`, served as `deployment`. `errors` are the
|
|
91
|
+
* schemas of every error it can be answered with, its middleware's included.
|
|
92
|
+
*/
|
|
93
|
+
const method = (
|
|
94
|
+
functions: Sdk.Functions,
|
|
95
|
+
callable: Callable.Any,
|
|
96
|
+
deployment: Callable.Deployment,
|
|
97
|
+
errors: ReadonlyArray<Schema.Top>,
|
|
98
|
+
) => {
|
|
99
|
+
const deployedName = deployment.name;
|
|
100
|
+
const span = { kind: "client", attributes: deployment.attributes } as const;
|
|
101
|
+
const call = Sdk.httpsCallable(functions, deployedName);
|
|
102
|
+
const encode = Schema.encodeUnknownEffect(
|
|
103
|
+
Schema.toCodecJson(callable.payload as Schema.Codec<unknown>),
|
|
104
|
+
);
|
|
105
|
+
const decode = Schema.decodeUnknownEffect(
|
|
106
|
+
Schema.toCodecJson(callable.success as Schema.Codec<unknown>),
|
|
107
|
+
);
|
|
108
|
+
const fail = (reason: CallableError.Reason) =>
|
|
109
|
+
new CallableError.CallableError({ callable: deployedName, reason });
|
|
110
|
+
const run = Effect.fn(
|
|
111
|
+
`CallableClient.${deployedName}`,
|
|
112
|
+
span,
|
|
113
|
+
)(function* (payload: unknown) {
|
|
114
|
+
const data = yield* encode(payload).pipe(
|
|
115
|
+
Effect.mapError((cause) => fail(new CallableError.EncodeError({ cause }))),
|
|
116
|
+
);
|
|
117
|
+
const result = yield* Effect.tryPromise({
|
|
118
|
+
try: () => call(data),
|
|
119
|
+
catch: (error) => error,
|
|
120
|
+
}).pipe(Effect.catch(decodeFailure));
|
|
121
|
+
return yield* decode(result.data).pipe(
|
|
122
|
+
Effect.mapError((cause) => fail(new CallableError.DecodeError({ cause }))),
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Turns what the SDK threw into the declared error it carries, or into a
|
|
128
|
+
* `CallableError`.
|
|
129
|
+
*/
|
|
130
|
+
const decodeFailure = (error: unknown): Effect.Effect<never, unknown> => {
|
|
131
|
+
if (!(error instanceof Sdk.FunctionsError)) {
|
|
132
|
+
return Effect.fail(fail(new CallableError.TransportError({ cause: error })));
|
|
133
|
+
}
|
|
134
|
+
// The SDK appends the HTTP status to the message of an error built from
|
|
135
|
+
// an answer, and status 0 to a network failure. The errors it makes
|
|
136
|
+
// itself, such as its timeout or a cancellation, carry no status.
|
|
137
|
+
const status = statusSuffix.exec(error.message)?.[1];
|
|
138
|
+
if (status === undefined || status === "0") {
|
|
139
|
+
// An answer arrived, but not one the SDK could read.
|
|
140
|
+
if (malformedAnswer.test(error.message)) {
|
|
141
|
+
return Effect.fail(fail(new CallableError.DecodeError({ cause: error })));
|
|
142
|
+
}
|
|
143
|
+
return Effect.fail(fail(new CallableError.TransportError({ cause: error })));
|
|
144
|
+
}
|
|
145
|
+
const code = error.code.replace(/^functions\//, "") as CallableError.ErrorCode;
|
|
146
|
+
const candidates = errors.filter((schema) => Callable.getErrorCode(schema) === code);
|
|
147
|
+
if (candidates.length === 0 || error.details === undefined) {
|
|
148
|
+
return Effect.fail(
|
|
149
|
+
fail(
|
|
150
|
+
new CallableError.ResponseError({
|
|
151
|
+
code,
|
|
152
|
+
message: error.message.replace(statusSuffix, ""),
|
|
153
|
+
details: error.details,
|
|
154
|
+
}),
|
|
155
|
+
),
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return Schema.decodeUnknownEffect(
|
|
159
|
+
Schema.toCodecJson(Schema.Union(candidates) as unknown as Schema.Codec<unknown>),
|
|
160
|
+
)(error.details).pipe(
|
|
161
|
+
Effect.mapError((cause) => fail(new CallableError.DecodeError({ cause }))),
|
|
162
|
+
Effect.flatMap(Effect.fail),
|
|
163
|
+
);
|
|
164
|
+
};
|
|
165
|
+
return run;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Builds the client of a Callable in no group, or of every Callable member of
|
|
170
|
+
* a Function Group.
|
|
171
|
+
*
|
|
172
|
+
* **When to use**
|
|
173
|
+
*
|
|
174
|
+
* Use to call Callables from the browser or any other client SDK program.
|
|
175
|
+
*
|
|
176
|
+
* **Details**
|
|
177
|
+
*
|
|
178
|
+
* Returns a record keyed by Callable name: `const { greet } = yield*
|
|
179
|
+
* CallableClient.make(Greet)` for a Callable in no group, and
|
|
180
|
+
* `const users = yield* CallableClient.make(Users)` for a group, where
|
|
181
|
+
* `users.create` calls `users-create`. A group's members of other kinds, which
|
|
182
|
+
* no client calls, are left out. The region of the
|
|
183
|
+
* {@link ClientFunctions.ClientFunctions} in context is bound once, here.
|
|
184
|
+
*
|
|
185
|
+
* Build a member's client from its group. A client built from the member on
|
|
186
|
+
* its own calls the name without the group's prefix, which the server doesn't
|
|
187
|
+
* export, and fails with `not-found`. Nothing checks this at runtime.
|
|
188
|
+
*
|
|
189
|
+
* Each call encodes the Payload with `Schema.toCodecJson`, sends it as is
|
|
190
|
+
* through the SDK's `httpsCallable`, and decodes the answer the same way.
|
|
191
|
+
*
|
|
192
|
+
* When the server answers with an error, the client strips the `functions/`
|
|
193
|
+
* prefix from its code and decodes its `details` against the declared errors
|
|
194
|
+
* that carry that code. It fails with the decoded error, so `Effect.catchTag`
|
|
195
|
+
* recovers from it. Every other failure is a
|
|
196
|
+
* {@link CallableError.CallableError}:
|
|
197
|
+
*
|
|
198
|
+
* - `EncodeError` when the Payload doesn't encode. Nothing is sent.
|
|
199
|
+
* - `DecodeError` when the success value doesn't decode, the `details` of
|
|
200
|
+
* an error don't decode against the declared errors with its code, as after
|
|
201
|
+
* the server and the client drift apart, or the answer isn't one the SDK
|
|
202
|
+
* can read: not a JSON object, or one without `data`.
|
|
203
|
+
* - `ResponseError` when the server answers with a code no declared error
|
|
204
|
+
* carries, or without details, as on a defect. `not-found` usually means a
|
|
205
|
+
* wrong region, or a name the server doesn't export, such as a group's
|
|
206
|
+
* member called without its group. A `deadline-exceeded` the server sends
|
|
207
|
+
* is a `ResponseError` too.
|
|
208
|
+
* - `TransportError` when no answer arrived: a network failure, the SDK's
|
|
209
|
+
* own timeout (`deadline-exceeded`), a cancelled call, or a deleted
|
|
210
|
+
* Functions instance. The SDK appends the HTTP status of an answer to its
|
|
211
|
+
* error's message as ` [<status>]`, and status 0 to a network failure. The
|
|
212
|
+
* client treats an error without that suffix, or with status 0, as no
|
|
213
|
+
* answer.
|
|
214
|
+
*
|
|
215
|
+
* Each call runs in a `kind: "client"` span named `CallableClient.<deployed
|
|
216
|
+
* name>`, such as `CallableClient.users-create`, with the attributes
|
|
217
|
+
* `rpc.system: "firebase_callable"`, `rpc.method` (the Callable's name),
|
|
218
|
+
* `rpc.service` (the group, for a member) and `faas.name` (the deployed name).
|
|
219
|
+
* The span isn't linked to the server's span of the same call: the two land in
|
|
220
|
+
* separate traces. It carries no uid; add one with `Effect.annotateCurrentSpan`
|
|
221
|
+
* around the call when you want it.
|
|
222
|
+
*
|
|
223
|
+
* @example Injecting a client as a service
|
|
224
|
+
* ```ts
|
|
225
|
+
* import { Context, Layer, Schema } from "effect";
|
|
226
|
+
* import { Callable } from "@visiolab/effect-firebase/functions";
|
|
227
|
+
* import { CallableClient } from "@visiolab/effect-firebase-client/functions";
|
|
228
|
+
*
|
|
229
|
+
* const Greet = Callable.make("greet", {
|
|
230
|
+
* payload: { name: Schema.String },
|
|
231
|
+
* success: Schema.String,
|
|
232
|
+
* });
|
|
233
|
+
*
|
|
234
|
+
* class GreetClient extends Context.Service<GreetClient, CallableClient.Client<typeof Greet>>()(
|
|
235
|
+
* "app/GreetClient",
|
|
236
|
+
* ) {
|
|
237
|
+
* static readonly layer = Layer.effect(GreetClient, CallableClient.make(Greet));
|
|
238
|
+
* }
|
|
239
|
+
* ```
|
|
240
|
+
*
|
|
241
|
+
* @example Calling the members of a group
|
|
242
|
+
* ```ts
|
|
243
|
+
* import { Effect, Schema } from "effect";
|
|
244
|
+
* import { Callable, FunctionGroup } from "@visiolab/effect-firebase/functions";
|
|
245
|
+
* import { CallableClient } from "@visiolab/effect-firebase-client/functions";
|
|
246
|
+
*
|
|
247
|
+
* const CreateUser = Callable.make("create", {
|
|
248
|
+
* payload: { name: Schema.String },
|
|
249
|
+
* success: Schema.String,
|
|
250
|
+
* });
|
|
251
|
+
*
|
|
252
|
+
* class Users extends FunctionGroup.make("users").add(CreateUser) {}
|
|
253
|
+
*
|
|
254
|
+
* const program = Effect.gen(function* () {
|
|
255
|
+
* const users = yield* CallableClient.make(Users);
|
|
256
|
+
* // Calls `users-create`.
|
|
257
|
+
* return yield* users.create({ name: "Ada" });
|
|
258
|
+
* });
|
|
259
|
+
* ```
|
|
260
|
+
*
|
|
261
|
+
* @see {@link ClientFunctions.ClientFunctions} for the region and emulator it calls
|
|
262
|
+
* @see {@link Functions.make} for serving a Callable in no group
|
|
263
|
+
* @see {@link Functions.group} for serving the members of a group
|
|
264
|
+
*
|
|
265
|
+
* @stability unstable
|
|
266
|
+
* @category constructors
|
|
267
|
+
* @since 0.3.0
|
|
268
|
+
*/
|
|
269
|
+
export const make = <T extends Callable.Any | FunctionGroup.Any>(
|
|
270
|
+
target: T,
|
|
271
|
+
): Effect.Effect<Client<T>, never, ClientFunctions.ClientFunctions> =>
|
|
272
|
+
Effect.map(Effect.service(ClientFunctions.ClientFunctions), ({ instance }) => {
|
|
273
|
+
if (!FunctionGroup.isFunctionGroup(target)) {
|
|
274
|
+
const callable = target as Callable.Any;
|
|
275
|
+
return {
|
|
276
|
+
[callable.name]: method(
|
|
277
|
+
instance,
|
|
278
|
+
callable,
|
|
279
|
+
Callable.deployment(callable),
|
|
280
|
+
Callable.errorSchemas(callable),
|
|
281
|
+
),
|
|
282
|
+
} as Client<T>;
|
|
283
|
+
}
|
|
284
|
+
const client: Record<string, ReturnType<typeof method>> = {};
|
|
285
|
+
for (const [name, member] of Object.entries(target.functions)) {
|
|
286
|
+
// Only Callables are called by a client.
|
|
287
|
+
if (member.kind !== "Callable") continue;
|
|
288
|
+
client[name] = method(
|
|
289
|
+
instance,
|
|
290
|
+
member as Callable.Any,
|
|
291
|
+
Callable.deployment(target, name),
|
|
292
|
+
Callable.errorSchemas(target, name),
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
return client as Client<T>;
|
|
296
|
+
});
|