firetender-admin 0.10.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 +20 -0
- package/README.md +349 -0
- package/dist/FiretenderCollection.d.ts +116 -0
- package/dist/FiretenderCollection.js +225 -0
- package/dist/FiretenderCollection.js.map +1 -0
- package/dist/FiretenderDoc.d.ts +172 -0
- package/dist/FiretenderDoc.js +361 -0
- package/dist/FiretenderDoc.js.map +1 -0
- package/dist/errors.d.ts +30 -0
- package/dist/errors.js +49 -0
- package/dist/errors.js.map +1 -0
- package/dist/firestore-deps-admin.d.ts +26 -0
- package/dist/firestore-deps-admin.js +62 -0
- package/dist/firestore-deps-admin.js.map +1 -0
- package/dist/firestore-deps-web.d.ts +10 -0
- package/dist/firestore-deps-web.js +29 -0
- package/dist/firestore-deps-web.js.map +1 -0
- package/dist/firestore-deps.d.ts +26 -0
- package/dist/firestore-deps.js +62 -0
- package/dist/firestore-deps.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/proxies.d.ts +62 -0
- package/dist/proxies.js +243 -0
- package/dist/proxies.js.map +1 -0
- package/dist/timestamps.d.ts +37 -0
- package/dist/timestamps.js +58 -0
- package/dist/timestamps.js.map +1 -0
- package/dist/ts-helpers.d.ts +14 -0
- package/dist/ts-helpers.js +19 -0
- package/dist/ts-helpers.js.map +1 -0
- package/package.json +72 -0
- package/src/FiretenderCollection.ts +311 -0
- package/src/FiretenderDoc.ts +483 -0
- package/src/errors.ts +47 -0
- package/src/firestore-deps-admin.ts +111 -0
- package/src/firestore-deps-web.ts +14 -0
- package/src/firestore-deps.ts +111 -0
- package/src/index.ts +29 -0
- package/src/proxies.ts +286 -0
- package/src/timestamps.ts +62 -0
- package/src/ts-helpers.ts +39 -0
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
addContextToError,
|
|
5
|
+
FiretenderInternalError,
|
|
6
|
+
FiretenderIOError,
|
|
7
|
+
FiretenderUsageError,
|
|
8
|
+
} from "./errors";
|
|
9
|
+
import {
|
|
10
|
+
addDoc,
|
|
11
|
+
collection,
|
|
12
|
+
CollectionReference,
|
|
13
|
+
doc,
|
|
14
|
+
DocumentReference,
|
|
15
|
+
DocumentSnapshot,
|
|
16
|
+
getDoc,
|
|
17
|
+
setDoc,
|
|
18
|
+
updateDoc,
|
|
19
|
+
} from "./firestore-deps";
|
|
20
|
+
import { watchFieldForChanges } from "./proxies";
|
|
21
|
+
import { assertIsDefined, DeepReadonly } from "./ts-helpers";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Public options for initializing a FiretenderDoc object.
|
|
25
|
+
*
|
|
26
|
+
* These will be added as needed (e.g., "readonly" for issue #1, possibly
|
|
27
|
+
* "queryPageLength" for issue #21).
|
|
28
|
+
*/
|
|
29
|
+
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
30
|
+
export type FiretenderDocOptions = {};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* All options when initializing a FiretenderDoc object.
|
|
34
|
+
*
|
|
35
|
+
* This type includes options meant for internal use (createDoc, initialData)
|
|
36
|
+
* as well as the options in FiretenderDocOptions.
|
|
37
|
+
*/
|
|
38
|
+
export type AllFiretenderDocOptions = FiretenderDocOptions & {
|
|
39
|
+
/**
|
|
40
|
+
* Does this FiretenderDoc represent a new document in Firestore?
|
|
41
|
+
*/
|
|
42
|
+
createDoc?: true;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The document's initial data, which must define a valid instance of the
|
|
46
|
+
* document according to its schema.
|
|
47
|
+
*/
|
|
48
|
+
initialData?: Record<string, any>;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A local representation of a Firestore document.
|
|
53
|
+
*/
|
|
54
|
+
export class FiretenderDoc<SchemaType extends z.SomeZodObject> {
|
|
55
|
+
/** Zod schema used to parse and validate the document's data */
|
|
56
|
+
readonly schema: SchemaType;
|
|
57
|
+
|
|
58
|
+
/** Firestore reference to this doc, or collection in which to create it */
|
|
59
|
+
private ref: DocumentReference | CollectionReference;
|
|
60
|
+
|
|
61
|
+
/** Firestore document ID; undefined for new docs not yet on Firestore */
|
|
62
|
+
private docID: string | undefined = undefined;
|
|
63
|
+
|
|
64
|
+
/** Is this a doc we presume does not yet exist in Firestore? */
|
|
65
|
+
private isNewDoc: boolean;
|
|
66
|
+
|
|
67
|
+
/** Use addDoc or setDoc to write all the data? If not, use updateDoc. */
|
|
68
|
+
private isSettingNewContents: boolean;
|
|
69
|
+
|
|
70
|
+
/** Local copy of the document data, parsed into the Zod type */
|
|
71
|
+
private data: z.infer<SchemaType> | undefined = undefined;
|
|
72
|
+
|
|
73
|
+
/** Proxy to intercept write (.w) access to the data and track the changes */
|
|
74
|
+
private dataProxy: ProxyHandler<z.infer<SchemaType>> | undefined = undefined;
|
|
75
|
+
|
|
76
|
+
/** Map from the dot-delimited field path (per updateDoc()) to new value */
|
|
77
|
+
private updates = new Map<string, any>();
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* If a load() call is already in progress, this is a list of promise
|
|
81
|
+
* resolutions to be called once the load is complete. Otherwise undefined.
|
|
82
|
+
*/
|
|
83
|
+
private resolvesWaitingForLoad:
|
|
84
|
+
| { resolve: () => void; reject: (reason?: any) => void }[]
|
|
85
|
+
| undefined;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param schema the Zod object schema describing this document's data.
|
|
89
|
+
* @param ref either a document reference specifying the full path of the
|
|
90
|
+
* document, or a collection reference specifying where a new document will
|
|
91
|
+
* be created.
|
|
92
|
+
* @param options optional parameters for the resulting FiretenderDoc; see
|
|
93
|
+
* FiretenderDocOptions for detail.
|
|
94
|
+
*/
|
|
95
|
+
constructor(
|
|
96
|
+
schema: SchemaType,
|
|
97
|
+
ref: DocumentReference | CollectionReference,
|
|
98
|
+
options: AllFiretenderDocOptions = {}
|
|
99
|
+
) {
|
|
100
|
+
this.schema = schema;
|
|
101
|
+
this.ref = ref;
|
|
102
|
+
this.isNewDoc = options.createDoc ?? false;
|
|
103
|
+
this.isSettingNewContents = this.isNewDoc;
|
|
104
|
+
if (options.initialData) {
|
|
105
|
+
this.data = schema.parse(options.initialData);
|
|
106
|
+
} else if (this.isNewDoc) {
|
|
107
|
+
throw ReferenceError(
|
|
108
|
+
"Initial data must be given when creating a new doc."
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (this.ref instanceof DocumentReference) {
|
|
112
|
+
this.docID = this.ref.path.split("/").pop();
|
|
113
|
+
} else if (!this.isNewDoc) {
|
|
114
|
+
throw TypeError(
|
|
115
|
+
"FiretenderDoc can only take a collection reference when creating a new document. Use .createNewDoc() if this is your intent."
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Returns a FiretenderDoc representing a new Firestore document.
|
|
122
|
+
*
|
|
123
|
+
* This method does not create the document in Firestore. To do so, call the
|
|
124
|
+
* write() method.
|
|
125
|
+
*
|
|
126
|
+
* @param schema the Zod object schema describing this document's data.
|
|
127
|
+
* @param ref either a document reference specifying the full path of the
|
|
128
|
+
* document, or a collection reference specifying where a new document will
|
|
129
|
+
* be created.
|
|
130
|
+
* @param initialData the document's initial data, which must define a valid
|
|
131
|
+
* instance of this document according to its schema.
|
|
132
|
+
* @param options optional parameters for the resulting FiretenderDoc; see
|
|
133
|
+
* FiretenderDocOptions for detail.
|
|
134
|
+
*/
|
|
135
|
+
static createNewDoc<SchemaType1 extends z.SomeZodObject>(
|
|
136
|
+
schema: SchemaType1,
|
|
137
|
+
ref: DocumentReference | CollectionReference,
|
|
138
|
+
initialData: z.input<SchemaType1>,
|
|
139
|
+
options: FiretenderDocOptions = {}
|
|
140
|
+
): FiretenderDoc<SchemaType1> {
|
|
141
|
+
const mergedOptions: AllFiretenderDocOptions = {
|
|
142
|
+
...options,
|
|
143
|
+
createDoc: true,
|
|
144
|
+
initialData,
|
|
145
|
+
};
|
|
146
|
+
return new FiretenderDoc(schema, ref, mergedOptions);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Creates a copy of this document. Returns a deep copy of its data with a
|
|
151
|
+
* specified or undefined Firestore ID and reference.
|
|
152
|
+
*
|
|
153
|
+
* This method does not create the document in Firestore. To do so, call the
|
|
154
|
+
* write() method. If a document ID or reference is not provided, those will
|
|
155
|
+
* be unset until the write.
|
|
156
|
+
*
|
|
157
|
+
* The location of the new document depends on the type of the `dest`
|
|
158
|
+
* argument:
|
|
159
|
+
* - `undefined` (default): It will be in the same collection and will be
|
|
160
|
+
* assigned a random ID when written to Firestore.
|
|
161
|
+
* - `string`: It will be in the same collection and have a document ID given
|
|
162
|
+
* by `dest`.
|
|
163
|
+
* - `string[]`: It will be in the specified subcollection and receive a
|
|
164
|
+
* random ID (if `type` does not give an ID for the deepest subcollection)
|
|
165
|
+
* or have the fully specified Firestore path (if `type` does).
|
|
166
|
+
* - `DocumentReference`: It will have the given Firestore reference.
|
|
167
|
+
* - `CollectionReference`: It will be in the given subcollection and have a
|
|
168
|
+
* randomly assigned ID upon writing.
|
|
169
|
+
*
|
|
170
|
+
* @param dest the location of the new document; see above for details.
|
|
171
|
+
* @param options optional parameters for the resulting FiretenderDoc; see
|
|
172
|
+
* FiretenderDocOptions for detail.
|
|
173
|
+
*/
|
|
174
|
+
copy(
|
|
175
|
+
dest:
|
|
176
|
+
| DocumentReference
|
|
177
|
+
| CollectionReference
|
|
178
|
+
| string
|
|
179
|
+
| string[]
|
|
180
|
+
| undefined = undefined,
|
|
181
|
+
options: AllFiretenderDocOptions = {}
|
|
182
|
+
): FiretenderDoc<SchemaType> {
|
|
183
|
+
if (!this.data) {
|
|
184
|
+
throw new FiretenderUsageError(
|
|
185
|
+
"You must call load() before making a copy."
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
let ref: DocumentReference | CollectionReference;
|
|
189
|
+
if (
|
|
190
|
+
dest instanceof DocumentReference ||
|
|
191
|
+
dest instanceof CollectionReference
|
|
192
|
+
) {
|
|
193
|
+
ref = dest;
|
|
194
|
+
} else if (Array.isArray(dest)) {
|
|
195
|
+
const path = this.ref.path.split("/");
|
|
196
|
+
if (path.length % 2 === 0) {
|
|
197
|
+
// If this doc has a ID for the deepest collection, remove it so that
|
|
198
|
+
// path always starts as a collection path.
|
|
199
|
+
path.length -= 1;
|
|
200
|
+
}
|
|
201
|
+
const collectionDepth = (path.length + 1) / 2;
|
|
202
|
+
if (dest.length < collectionDepth - 1 || dest.length > collectionDepth) {
|
|
203
|
+
throw new FiretenderUsageError(
|
|
204
|
+
"copy() with a path array requires an ID for all collections and subcollections, except optionally the last."
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
dest.forEach((id, index) => {
|
|
208
|
+
path[index * 2 + 1] = id;
|
|
209
|
+
});
|
|
210
|
+
ref =
|
|
211
|
+
dest.length === collectionDepth
|
|
212
|
+
? doc(this.ref.firestore, path[0], ...path.slice(1))
|
|
213
|
+
: collection(this.ref.firestore, path[0], ...path.slice(1));
|
|
214
|
+
} else {
|
|
215
|
+
// For a string or undefined ...
|
|
216
|
+
const collectionRef =
|
|
217
|
+
this.ref instanceof DocumentReference ? this.ref.parent : this.ref;
|
|
218
|
+
if (dest) {
|
|
219
|
+
ref = doc(collectionRef, dest);
|
|
220
|
+
} else {
|
|
221
|
+
ref = collectionRef;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const mergedOptions: AllFiretenderDocOptions = {
|
|
225
|
+
...options,
|
|
226
|
+
createDoc: true,
|
|
227
|
+
initialData: this.data,
|
|
228
|
+
};
|
|
229
|
+
return new FiretenderDoc(this.schema, ref, mergedOptions);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The document's ID string.
|
|
234
|
+
*
|
|
235
|
+
* @throws Throws an error if the document does not yet have an ID.
|
|
236
|
+
*/
|
|
237
|
+
get id(): string {
|
|
238
|
+
if (!this.docID) {
|
|
239
|
+
throw new FiretenderUsageError(
|
|
240
|
+
"id can only be accessed after the new doc has been written."
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
return this.docID;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The document's Firestore reference.
|
|
248
|
+
*
|
|
249
|
+
* @throws Throws an error if the document does not yet have a reference.
|
|
250
|
+
*/
|
|
251
|
+
get docRef(): DocumentReference {
|
|
252
|
+
if (!(this.ref instanceof DocumentReference)) {
|
|
253
|
+
throw new FiretenderUsageError(
|
|
254
|
+
"docRef can only be accessed after the new doc has been written."
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
return this.ref;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Is this a new doc that has not yet been written to Firestore?
|
|
262
|
+
*/
|
|
263
|
+
isNew(): boolean {
|
|
264
|
+
return this.isNewDoc;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Does the document contain data, either because it was successfully loaded
|
|
269
|
+
* or is newly created?
|
|
270
|
+
*/
|
|
271
|
+
isLoaded(): boolean {
|
|
272
|
+
return this.data !== undefined;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Does this document contain data that has not yet been written to Firestore?
|
|
277
|
+
*/
|
|
278
|
+
isPendingWrite(): boolean {
|
|
279
|
+
return this.isSettingNewContents || this.updates.size > 0;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Loads this document's data from Firestore.
|
|
284
|
+
*
|
|
285
|
+
* @param force force a read from Firestore. Normally load() does nothing if
|
|
286
|
+
* the document already contains data.
|
|
287
|
+
*/
|
|
288
|
+
async load(force = false): Promise<this> {
|
|
289
|
+
if (this.isNewDoc || this.ref instanceof CollectionReference) {
|
|
290
|
+
throw new FiretenderUsageError(
|
|
291
|
+
"load() should not be called for new documents."
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (!this.data || force) {
|
|
295
|
+
if (this.resolvesWaitingForLoad !== undefined) {
|
|
296
|
+
await new Promise<void>((resolve, reject) => {
|
|
297
|
+
assertIsDefined(this.resolvesWaitingForLoad);
|
|
298
|
+
this.resolvesWaitingForLoad.push({ resolve, reject });
|
|
299
|
+
});
|
|
300
|
+
} else {
|
|
301
|
+
this.resolvesWaitingForLoad = [];
|
|
302
|
+
let snapshot: DocumentSnapshot;
|
|
303
|
+
try {
|
|
304
|
+
snapshot = await getDoc(this.ref);
|
|
305
|
+
} catch (error) {
|
|
306
|
+
addContextToError(error, "getDoc", this.ref);
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
// DocumentSnapshot.prototype.exists is a boolean for
|
|
310
|
+
// "firebase-admin/firestore" and a function for "firebase/firestore".
|
|
311
|
+
if (
|
|
312
|
+
(typeof snapshot.exists === "boolean" && !snapshot.exists) ||
|
|
313
|
+
(typeof snapshot.exists === "function" && !(snapshot.exists as any)())
|
|
314
|
+
) {
|
|
315
|
+
const error = new FiretenderIOError(
|
|
316
|
+
`Document does not exist: "${this.ref.path}"`
|
|
317
|
+
);
|
|
318
|
+
this.resolvesWaitingForLoad.forEach((wait) => wait.reject(error));
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
this.data = this.schema.parse(snapshot.data());
|
|
322
|
+
// Dereference the old proxy, if any, to force a recapture of data.
|
|
323
|
+
this.dataProxy = undefined;
|
|
324
|
+
this.resolvesWaitingForLoad.forEach((wait) => wait.resolve());
|
|
325
|
+
this.resolvesWaitingForLoad = undefined;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return this;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Read-only accessor to the contents of this document.
|
|
333
|
+
*/
|
|
334
|
+
get r(): DeepReadonly<z.infer<SchemaType>> {
|
|
335
|
+
if (!this.data) {
|
|
336
|
+
throw new FiretenderUsageError(
|
|
337
|
+
"load() must be called before reading the document."
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
return this.data as DeepReadonly<z.infer<SchemaType>>;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Writable accessor to update the contents of this document.
|
|
345
|
+
*
|
|
346
|
+
* Only use this accessor when making changes to the doc. The .r accessor is
|
|
347
|
+
* considerably more efficient when reading.
|
|
348
|
+
*/
|
|
349
|
+
get w(): z.infer<SchemaType> {
|
|
350
|
+
if (this.isSettingNewContents) {
|
|
351
|
+
// No need to monitor changes if we're setting rather than updating.
|
|
352
|
+
return this.data as z.infer<SchemaType>;
|
|
353
|
+
}
|
|
354
|
+
if (!this.dataProxy) {
|
|
355
|
+
if (!this.data) {
|
|
356
|
+
// TODO #23: Consider being able to update a doc without loading it.
|
|
357
|
+
throw new FiretenderUsageError(
|
|
358
|
+
"load() must be called before updating the document."
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
this.dataProxy = watchFieldForChanges(
|
|
362
|
+
[],
|
|
363
|
+
this.schema,
|
|
364
|
+
this.data,
|
|
365
|
+
this.addToUpdateList.bind(this)
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
return this.dataProxy as z.infer<SchemaType>;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Writable accessor to overwrite all the document data.
|
|
373
|
+
*/
|
|
374
|
+
set w(newData: z.input<SchemaType>) {
|
|
375
|
+
this.data = this.schema.parse(newData);
|
|
376
|
+
this.isSettingNewContents = true;
|
|
377
|
+
this.dataProxy = undefined;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Writes the document or any updates to Firestore.
|
|
382
|
+
*/
|
|
383
|
+
async write(): Promise<this> {
|
|
384
|
+
// For new docs, this.data should contain its initial state.
|
|
385
|
+
if (this.isSettingNewContents) {
|
|
386
|
+
assertIsDefined(this.data);
|
|
387
|
+
if (this.ref instanceof DocumentReference) {
|
|
388
|
+
try {
|
|
389
|
+
await setDoc(this.ref, this.data);
|
|
390
|
+
} catch (error) {
|
|
391
|
+
addContextToError(error, "setDoc", this.ref, this.data);
|
|
392
|
+
throw error;
|
|
393
|
+
}
|
|
394
|
+
} else {
|
|
395
|
+
try {
|
|
396
|
+
this.ref = await addDoc(this.ref, this.data);
|
|
397
|
+
} catch (error: any) {
|
|
398
|
+
addContextToError(error, "addDoc", this.ref, this.data);
|
|
399
|
+
throw error;
|
|
400
|
+
}
|
|
401
|
+
this.docID = this.ref.path.split("/").pop(); // ID is last part of path.
|
|
402
|
+
}
|
|
403
|
+
this.isSettingNewContents = false;
|
|
404
|
+
this.isNewDoc = false;
|
|
405
|
+
}
|
|
406
|
+
// For existing docs, this.updates should contain a list of changes.
|
|
407
|
+
else {
|
|
408
|
+
if (!(this.ref instanceof DocumentReference)) {
|
|
409
|
+
// We should never get here.
|
|
410
|
+
throw new FiretenderInternalError(
|
|
411
|
+
"Internal error. Firetender object should always reference a document when updating an existing doc."
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
if (this.updates.size > 0) {
|
|
415
|
+
const updateData = Object.fromEntries(this.updates);
|
|
416
|
+
try {
|
|
417
|
+
await updateDoc(this.ref, updateData);
|
|
418
|
+
} catch (error: any) {
|
|
419
|
+
addContextToError(error, "updateDoc", this.ref, updateData);
|
|
420
|
+
throw error;
|
|
421
|
+
}
|
|
422
|
+
this.updates.clear();
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return this;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Updates the document's data with a single call.
|
|
430
|
+
*
|
|
431
|
+
* This function loads the document's data, if necessary; calls the given
|
|
432
|
+
* function to make changes to the data; then write the changes to Firestore.
|
|
433
|
+
* If nothing else, it helps you avoid forgetting to call .write()!
|
|
434
|
+
*
|
|
435
|
+
* @param mutator function that accepts a writable data object and makes
|
|
436
|
+
* changes to it.
|
|
437
|
+
*/
|
|
438
|
+
async update(mutator: (data: z.infer<SchemaType>) => void): Promise<this> {
|
|
439
|
+
await this.load();
|
|
440
|
+
mutator(this.w);
|
|
441
|
+
await this.write();
|
|
442
|
+
return this;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
//////////////////////////////////////////////////////////////////////////////
|
|
446
|
+
// Private functions
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Adds a field and its new value to the list of updates to be passed to
|
|
450
|
+
* Firestore's updateDoc(). Called when the proxies detect changes to the
|
|
451
|
+
* document data.
|
|
452
|
+
*/
|
|
453
|
+
private addToUpdateList<FieldSchemaType extends z.ZodTypeAny>(
|
|
454
|
+
fieldPath: string[],
|
|
455
|
+
newValue: z.infer<FieldSchemaType>
|
|
456
|
+
): void {
|
|
457
|
+
let pathString = "";
|
|
458
|
+
if (this.updates.size > 0) {
|
|
459
|
+
// If there is already a list of mutations to send to Firestore, check if
|
|
460
|
+
// a parent of this update is in it. Objects in the update list are
|
|
461
|
+
// references into this.data, so the parent field will automatically
|
|
462
|
+
// reflect this change; no additional Firestore mutation is needed.
|
|
463
|
+
if (
|
|
464
|
+
fieldPath.some((field, i) => {
|
|
465
|
+
pathString = pathString ? `${pathString}.${field}` : field;
|
|
466
|
+
return i < fieldPath.length - 1 && this.updates.has(pathString);
|
|
467
|
+
})
|
|
468
|
+
) {
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
// Remove any previous updates that this one overwrites.
|
|
472
|
+
this.updates.forEach((value, key) => {
|
|
473
|
+
if (key.startsWith(pathString)) {
|
|
474
|
+
this.updates.delete(key);
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
} else {
|
|
478
|
+
// Shortcut for the common case of a single update being made.
|
|
479
|
+
pathString = fieldPath.join(".");
|
|
480
|
+
}
|
|
481
|
+
this.updates.set(pathString, newValue);
|
|
482
|
+
}
|
|
483
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { CollectionReference, DocumentReference } from "./firestore-deps";
|
|
2
|
+
|
|
3
|
+
export class FiretenderError extends Error {}
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Something went wrong with a Firestore operation, such as trying to load a
|
|
7
|
+
* document that does not exist.
|
|
8
|
+
*/
|
|
9
|
+
export class FiretenderIOError extends FiretenderError {}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The caller did something wrong: a function was called at the wrong time or
|
|
13
|
+
* with the wrong parameters.
|
|
14
|
+
*/
|
|
15
|
+
export class FiretenderUsageError extends FiretenderError {}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Something went wrong internally. These errors indicate a bug in Firetender.
|
|
19
|
+
*/
|
|
20
|
+
export class FiretenderInternalError extends FiretenderError {}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Adds a "firetenderContext" property to the given error.
|
|
24
|
+
*
|
|
25
|
+
* @param error the error to which the context is added. If `error` is not an
|
|
26
|
+
* object, this call does not modify it.
|
|
27
|
+
* @param call the name of the Firestore function in which the error occurred.
|
|
28
|
+
* @param ref the path of the target document or collection, if any.
|
|
29
|
+
* @param data arbitrary data associated with this call.
|
|
30
|
+
*/
|
|
31
|
+
export function addContextToError(
|
|
32
|
+
error: any,
|
|
33
|
+
call: string,
|
|
34
|
+
ref?: DocumentReference | CollectionReference,
|
|
35
|
+
data?: any
|
|
36
|
+
) {
|
|
37
|
+
if (typeof error !== "object") {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
error.firetenderContext = { call };
|
|
41
|
+
if (ref) {
|
|
42
|
+
error.firetenderContext.ref = ref.path;
|
|
43
|
+
}
|
|
44
|
+
if (data !== undefined) {
|
|
45
|
+
error.firetenderContext.data = data;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provides all dependencies normally imported from "firebase/firestore".
|
|
3
|
+
*
|
|
4
|
+
* For web clients, the "firebase/firestore" API is simply re-exported.
|
|
5
|
+
*
|
|
6
|
+
* For the server client API, ...
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
CollectionGroup,
|
|
11
|
+
CollectionReference,
|
|
12
|
+
DocumentReference,
|
|
13
|
+
DocumentSnapshot,
|
|
14
|
+
FieldValue,
|
|
15
|
+
Filter,
|
|
16
|
+
Firestore,
|
|
17
|
+
Query,
|
|
18
|
+
QuerySnapshot,
|
|
19
|
+
Timestamp,
|
|
20
|
+
UpdateData,
|
|
21
|
+
WithFieldValue,
|
|
22
|
+
} from "@google-cloud/firestore";
|
|
23
|
+
|
|
24
|
+
export const FIRESTORE_DEPS_TYPE: "web" | "admin" = "admin";
|
|
25
|
+
|
|
26
|
+
export {
|
|
27
|
+
CollectionReference,
|
|
28
|
+
DocumentReference,
|
|
29
|
+
DocumentSnapshot,
|
|
30
|
+
Firestore,
|
|
31
|
+
Query,
|
|
32
|
+
QuerySnapshot,
|
|
33
|
+
Timestamp,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type QueryConstraint = Filter;
|
|
37
|
+
export const where = Filter.where;
|
|
38
|
+
export const arrayRemove = FieldValue.arrayRemove;
|
|
39
|
+
export const deleteField = FieldValue.delete;
|
|
40
|
+
export const serverTimestamp = FieldValue.serverTimestamp;
|
|
41
|
+
|
|
42
|
+
export const isServerTimestamp = (x: any) => x instanceof FieldValue;
|
|
43
|
+
|
|
44
|
+
export const addDoc = <T>(
|
|
45
|
+
ref: CollectionReference<T>,
|
|
46
|
+
data: WithFieldValue<T>
|
|
47
|
+
): Promise<DocumentReference<T>> => ref.add(data);
|
|
48
|
+
|
|
49
|
+
export const collection = (
|
|
50
|
+
firestoreOrRef: Firestore | CollectionReference,
|
|
51
|
+
path: string,
|
|
52
|
+
...pathSegments: string[]
|
|
53
|
+
): CollectionReference =>
|
|
54
|
+
firestoreOrRef instanceof Firestore
|
|
55
|
+
? firestoreOrRef.collection([path, ...pathSegments].join("/"))
|
|
56
|
+
: firestoreOrRef.firestore.collection(
|
|
57
|
+
[firestoreOrRef.path, path, ...pathSegments].join("/")
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
export const collectionGroup = (
|
|
61
|
+
firestore: Firestore,
|
|
62
|
+
collectionID: string
|
|
63
|
+
): CollectionGroup => firestore.collectionGroup(collectionID);
|
|
64
|
+
|
|
65
|
+
export const deleteDoc = async (
|
|
66
|
+
ref: DocumentReference<unknown>
|
|
67
|
+
): Promise<void> => {
|
|
68
|
+
await ref.delete();
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const doc = (
|
|
72
|
+
firestoreOrRef: Firestore | CollectionReference,
|
|
73
|
+
path?: string,
|
|
74
|
+
...pathSegments: string[]
|
|
75
|
+
): DocumentReference =>
|
|
76
|
+
firestoreOrRef instanceof Firestore
|
|
77
|
+
? firestoreOrRef.doc([path, ...pathSegments].join("/"))
|
|
78
|
+
: path
|
|
79
|
+
? firestoreOrRef.doc([path, ...pathSegments].join("/"))
|
|
80
|
+
: firestoreOrRef.doc();
|
|
81
|
+
|
|
82
|
+
export const getDoc = <T>(
|
|
83
|
+
ref: DocumentReference<T>
|
|
84
|
+
): Promise<DocumentSnapshot<T>> => ref.get();
|
|
85
|
+
|
|
86
|
+
export const getDocs = <T>(query: Query<T>): Promise<QuerySnapshot<T>> =>
|
|
87
|
+
query.get();
|
|
88
|
+
|
|
89
|
+
export const query = <T>(
|
|
90
|
+
ref: Query<T>,
|
|
91
|
+
...queryConstraints: QueryConstraint[]
|
|
92
|
+
): Query<T> =>
|
|
93
|
+
queryConstraints.length === 0
|
|
94
|
+
? ref
|
|
95
|
+
: queryConstraints.length === 1
|
|
96
|
+
? ref.where(queryConstraints[0])
|
|
97
|
+
: ref.where(Filter.and(...queryConstraints));
|
|
98
|
+
|
|
99
|
+
export const setDoc = async <T>(
|
|
100
|
+
ref: DocumentReference<T>,
|
|
101
|
+
data: WithFieldValue<T>
|
|
102
|
+
): Promise<void> => {
|
|
103
|
+
await ref.set(data);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const updateDoc = async <T>(
|
|
107
|
+
ref: DocumentReference<T>,
|
|
108
|
+
data: UpdateData<T>
|
|
109
|
+
): Promise<void> => {
|
|
110
|
+
await ref.update(data);
|
|
111
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provides all dependencies normally imported from "firebase/firestore".
|
|
3
|
+
*
|
|
4
|
+
* For web clients, the "firebase/firestore" API is simply re-exported.
|
|
5
|
+
*
|
|
6
|
+
* For the server client API, ...
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export * from "firebase/firestore";
|
|
10
|
+
|
|
11
|
+
export const FIRESTORE_DEPS_TYPE: "web" | "admin" = "web";
|
|
12
|
+
|
|
13
|
+
export const isServerTimestamp = (data: any) =>
|
|
14
|
+
data._methodName === "serverTimestamp";
|