firetender-admin

0.15.3 • Public • Published

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
    • Listen for changes and update the object if it has not been locally modified. Provide an onChange() callback option. (#14)
    • Support the Firestore transaction API. (#15)
  • 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.

Package Sidebar

Install

npm i firetender-admin

Weekly Downloads

11

Version

0.15.3

License

MIT

Unpacked Size

375 kB

Total Files

65

Last publish

Collaborators

  • jakes-space