@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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -2
  3. package/dist/_virtual/_rolldown/runtime.mjs +12 -0
  4. package/dist/app/App.d.mts +117 -0
  5. package/dist/app/App.mjs +149 -0
  6. package/dist/app/App.mjs.map +1 -0
  7. package/dist/app/index.d.mts +2 -0
  8. package/dist/app/index.mjs +1 -0
  9. package/dist/app/internal/app.mjs +20 -0
  10. package/dist/app/internal/app.mjs.map +1 -0
  11. package/dist/firestore/ClientDocumentSnapshot.d.mts +106 -0
  12. package/dist/firestore/ClientDocumentSnapshot.mjs +86 -0
  13. package/dist/firestore/ClientDocumentSnapshot.mjs.map +1 -0
  14. package/dist/firestore/ClientFirestore.d.mts +331 -0
  15. package/dist/firestore/ClientFirestore.mjs +146 -0
  16. package/dist/firestore/ClientFirestore.mjs.map +1 -0
  17. package/dist/firestore/ClientFirestoreValue.d.mts +61 -0
  18. package/dist/firestore/ClientFirestoreValue.mjs +62 -0
  19. package/dist/firestore/ClientFirestoreValue.mjs.map +1 -0
  20. package/dist/firestore/ClientQuerySnapshot.d.mts +52 -0
  21. package/dist/firestore/ClientQuerySnapshot.mjs +58 -0
  22. package/dist/firestore/ClientQuerySnapshot.mjs.map +1 -0
  23. package/dist/firestore/index.d.mts +5 -0
  24. package/dist/firestore/index.mjs +5 -0
  25. package/dist/firestore/internal/firestore.mjs +158 -0
  26. package/dist/firestore/internal/firestore.mjs.map +1 -0
  27. package/dist/firestore/internal/firestoreValue.mjs +43 -0
  28. package/dist/firestore/internal/firestoreValue.mjs.map +1 -0
  29. package/dist/firestore/internal/snapshotMetadata.mjs +15 -0
  30. package/dist/firestore/internal/snapshotMetadata.mjs.map +1 -0
  31. package/dist/functions/CallableClient.d.mts +139 -0
  32. package/dist/functions/CallableClient.mjs +215 -0
  33. package/dist/functions/CallableClient.mjs.map +1 -0
  34. package/dist/functions/ClientFunctions.d.mts +112 -0
  35. package/dist/functions/ClientFunctions.mjs +105 -0
  36. package/dist/functions/ClientFunctions.mjs.map +1 -0
  37. package/dist/functions/index.d.mts +3 -0
  38. package/dist/functions/index.mjs +3 -0
  39. package/package.json +63 -3
  40. package/src/app/App.ts +163 -0
  41. package/src/app/index.ts +7 -0
  42. package/src/app/internal/app.ts +35 -0
  43. package/src/firestore/ClientDocumentSnapshot.ts +156 -0
  44. package/src/firestore/ClientFirestore.ts +410 -0
  45. package/src/firestore/ClientFirestoreValue.ts +94 -0
  46. package/src/firestore/ClientQuerySnapshot.ts +93 -0
  47. package/src/firestore/index.ts +30 -0
  48. package/src/firestore/internal/firestore.ts +264 -0
  49. package/src/firestore/internal/firestoreValue.ts +58 -0
  50. package/src/firestore/internal/snapshotMetadata.ts +14 -0
  51. package/src/functions/CallableClient.ts +296 -0
  52. package/src/functions/ClientFunctions.ts +149 -0
  53. 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
+ });