firetender
v0.16.3
Published
Typescript wrapper for Firestore documents
Downloads
80
Maintainers
Readme
Firetender
Firetender takes your Zod data schema ...
const itemSchema = z.object({
description: z.string().optional(),
count: z.number().nonnegative().integer(),
tags: z.array(z.string()).default([]),
});
associates it with a Firestore collection ...
const itemCollection = new FiretenderCollection(itemSchema, db, "items");
and provides you with typesafe, validated Firestore documents that are easy to use and understand.
// Add a document to the collection.
await itemCollection.newDoc("foo", { count: 0, tags: ["needs +1"] }).write();
// Read the document "bar", then update it.
const itemDoc = await itemCollection.existingDoc("bar").load();
const count = itemDoc.r.count;
await itemDoc.update((item) => {
item.tags.push("needs +1");
});
// Increment the count of all docs with a "needs +1" tag.
await Promise.all(
itemCollection
.query(where("tags", "array-contains", "needs +1"))
.map((itemDoc) =>
itemDoc.update((item) => {
item.count += 1;
delete item.tags["needs +1"];
})
)
);
Changes to the document data are monitored, and only modified fields are updated on Firestore.
Usage
To illustrate in more detail, let's run through the basics of defining, creating, modifying, and copying a Firestore document.
Initialize Cloud Firestore
The first step is the usual Firestore configuration and initialization. See the Firestore quickstart for details.
import { doc, initializeApp } from "firebase/app";
import { getFirestore } from "firebase/firestore";
// TODO: Replace the following with your app's Firebase project configuration.
// See: https://firebase.google.com/docs/web/learn-more#config-object
const firebaseConfig = {
// ...
};
const app = initializeApp(firebaseConfig);
const firestore = getFirestore(app);
Define a collection and its schema
Firetender uses Zod to define the schema and validation rules for a collection's documents; if you've used Joi or Yup, you will find Zod very similar. In the example below, I've defined a schema for types of pizza. I was a little hungry when I wrote this.
import {
FiretenderCollection,
nowTimestamp,
timestampSchema
} from "firetender";
import { z } from "zod";
const pizzaSchema = z.object({
name: z.string(),
description: z.string().optional(),
creationTime: timestampSchema,
toppings: z.record(
z.string(),
z.object({
isIncluded: z.boolean().default(true),
surcharge: z.number().positive().optional(),
placement: z.enum(["left", "right", "entire"]).default("entire"),
})
.refine((topping) => topping.isIncluded || topping.surcharge, {
message: "Toppings that are not included must have a surcharge.",
path: ["surcharge"],
})
),
basePrice: z.number().optional(),
tags: z.array(z.string()).default([]),
});
const pizzaCollection = new FiretenderCollection(
pizzaSchema,
firestore,
"pizzas",
{ creationTime: nowTimestamp() }
);
Optional records and arrays should typically use .default()
to provide an
empty instances when missing. That isn't required, but it makes accessing these
fields simpler because they will always be defined. The downside is that empty
fields are not pruned and will appear in Firestore.
Add a document
Let's add a document to the pizzas
collection, with an ID of margherita
. We
use the collection's .newDoc()
to produce a FiretenderDoc
representing a new
document, initialized with validated data. This object is purely local until it
is written to Firestore by calling .write()
. Don't forget to do that.
const docRef = doc(db, "pizzas", "margherita");
const pizza = pizzaFactory.newDoc(docRef, {
name: "Margherita",
description: "Neapolitan style pizza"
toppings: { "fresh mozzarella": {}, "fresh basil": {} },
tags: ["traditional"],
});
await pizza.write();
If you don't care about the doc ID, pass a collection reference to .newDoc()
and Firestore will assign an ID at random. This ID can be read from .id
or
.docRef
after the document has been written.
Read and modify a document
To access an existing document, pass its reference to the collection's
.existingDoc()
method. To read it, call .load()
and access its data with
the .r
property; see the example below. To make changes, use .w
then call
.write()
. Reading and updating can be done in combination:
const meats = ["pepperoni", "chicken", "sausage"];
const pizza = await pizzaCollection.existingDoc(docRef).load();
const isMeatIncluded = Object.entries(pizza.r.toppings).some(
([name, topping]) => topping.isIncluded && name in meats
);
if (!isMeatIncluded) {
pizza.w.toppings.tags.push("vegetarian");
}
await pizza.write();
The .r
and .w
properties point to the same data, with the read-only accessor
typed accordingly. Reading from .r
is more efficient, as .w
builds a chain
of proxies to track updates.
Update a document in a single call
The .update()
method is a convenience method to load and update a document.
It allows a slightly cleaner implementation of the above example --- and saves
you from forgetting to call .write()
!
const meats = ["pepperoni", "chicken", "sausage"];
await pizzaCollection.existingDoc(docRef).update((pizza) => {
const isMeatIncluded = Object.entries(pizza.r.toppings).some(
([name, topping]) => topping.isIncluded && name in meats
);
if (!isMeatIncluded) {
pizza.w.toppings.tags.push("vegetarian");
}
});
Make a copy
Finally, use .copy()
to get a deep copy of the document. If an ID is not
specified, it will be assigned randomly when the new doc is added to Firestore.
The copy is solely local until .write()
is called.
const sourceRef = doc(db, "pizza", "margherita");
const sourcePizza = await pizzaCollection.existingDoc(sourceRef).load();
const newPizza = sourcePizza.copy("meaty margh");
newPizza.name = "Meaty Margh";
newPizza.toppings.sausage = {};
newPizza.toppings.pepperoni = { included: false, surcharge: 1.25 };
newPizza.toppings.chicken = { included: false, surcharge: 1.50 };
delete newPizza.description;
delete newPizza.toppings["fresh basil"];
delete newPizza.tags.vegetarian;
newPizza.write();
Note the use of the delete
operator to remove optional fields and record and
array items.
Get all docs in a collection
You can retrieve all the documents in a collection or subcollection:
const docs = await pizzaCollection().getAllDocs();
docs
will contain an array of FiretenderDoc
objects for all entries in the
pizzas collection. To get the contents of a subcollection, provide the ID(s) of
its parent collection (and subcollections) to getAllDocs()
.
Query a collection or subcollection
To query a collection, call query()
and pass in where
clauses. The
Firestore how-to
guide provides
many examples of simple and compound queries.
const veggieOptions = await pizzaCollection.query(
where("tags", "array-contains", "vegetarian")
);
const cheapClassics = await pizzaCollection.query(
where("baseprice", "<=", 10),
where("tags", "array-contains", "traditional")
);
To query a specific subcollection, provide the ID(s) of its parent collection
(and subcollections) as the first argument of query()
.
To perform a collection group query across all instances of a particular subcollection, leave out the IDs. From the Firestore how-to example, you could retrieve all parks from all cities with this query:
const cityLandmarkSchema = z.object({
name: z.string(),
type: z.string(),
});
const cityLandmarkCollection = new FiretenderCollection(
cityLandmarkSchema,
[firestore, "cities", "landmarks"],
{}
);
const beijingParks = await cityLandmarkCollection.query(
"BJ",
where("type", "==", "park"))
);
// Resulting array contains the document for Jingshan Park.
const allParks = await cityLandmarkCollection.query(
where("type", "==", "park")
);
// Resulting array has docs for Griffith Park, Ueno Park, and Jingshan Park.
Delete a document
To delete a document from the cities example:
const citySchema = z.object({ /* ... */ });
const cityCollection = new FiretenderCollection(
citySchema, [firestore, "cities"], {}
);
await cityCollection.delete("LA");
Subcollections are not deleted; in this example, the LA landmark docs would
remain. To also delete a document's subcollections, use query()
to get lists
of its subcollections' docs, then call delete()
on each doc. The Firestore
guide recommends only performing such unbounded batched deletions from a trusted
server environment.
Update all matching documents
In an inventory of items, markup by 10% all items awaiting a price increase.
const itemSchema = z.object({
name: z.string(),
price: z.number().nonnegative(),
tags: z.array(z.string()),
});
const inventoryCollection = new FiretenderCollection(itemSchema, [
firestore,
"inventory",
]);
await Promise.all(
inventoryCollection
.query(where("tags", "array-contains", "awaiting-price-increase"))
.map((itemDoc) =>
itemDoc.update((data) => {
data.price *= 1.1;
delete data.tags["awaiting-price-increase"];
})
)
);
TODO
The full list of issues is tracked on Github. Here are some features on the roadmap:
- Documentation
- Compile JSDoc to an API reference page in markdown. (#13)
- Concurrency
- Improved timestamp handling, tests (multiple issues)
Alternatives
This project is not stable yet. If you're looking for a more mature Firestore helper, check out:
Vuefire and Reactfire for integration with their respective frameworks.
Fireschema: Another strongly typed framework for building and using schemas in Firestore.
firestore-fp: If you like functional programming.
simplyfire: Another simplified API that is focused more on querying. (And kudos to the author for its great name.)
I'm sure there are many more, and apologies if I missed your favorite.