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.
To illustrate in more detail, let's run through the basics of defining, creating, modifying, and copying a Firestore document.
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);
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.
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.
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.
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");
}
});
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.
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()
.
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.
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.
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"];
})
)
);
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)
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.