| paths |
packages/db/** |
packages/cloud/** |
packages/app/** |
packages/admin/** |
|
Our entire database schema is located in packages/db.
All known Firestore collections can be found in packages/db/data.ts. This fuels the type-safety found in modules like packages/app/util/useProjectCollection.tsx and packages/cloud/util/getProjectCollection.ts.
Always think VERY HARD before modifying our schema! Consider different approaches and ask for feedback before making significant changes. Our schema is the very core of our system and changes should not be made lightly. Unlike a relational database, Firestore schemas must be designed with queries considered in advance! Firestore is wonderful in that it makes your app "always up to date" without ever needing to refresh the page, but the tradeoff is inflexible querying (no joins). For example, if you want to be able to efficiently display a paged list of Orders, the Order DB type should contain all the data needed to render the list the way you want without performing an additional fetch per-order. So if you want the list of Orders to show the Customer name, it's not enough to just store Order.customerId; the actual name (and profile pic if you want it) needs to be embedded inside the Order object. Typically we do this sort of thing with "stubs", so you'd have Order.customer which would be a CustomerStub and would contain a subset of properties like Customer.name, Customer.avatar, etc. using a Pick<> TypeScript type.
We endeavor to make all DB objects "plain old JavaScript objects" without any of Firestore's "fancy" classes like Timestamp. DB objects should be JSON serializable. Firestore's SDK will by default give you "Document data" separate from "Document ID", but we like to combine these into one object with an id property like this:
export type Template = {
id: string;
variations?: TemplateVariations | null;
}
This way we can pass around single objects (or arrays of objects) without needing to pass IDs separately.
For "array" type properties, we greatly prefer using a Record<> type instead of relying on Firestore's internal array ordering. This forces us to have some durable ID property which will serve as the Record key, and helps to facilitate concurrent data modification. Here's a simple example:
import { flattenObjectProperty, OmitId } from "./flatten";
export type TemplateVariations = Record<string, OmitId<TemplateVariation>>;
export type TemplateVariation = {
id: string;
created: number;
/** Sort order, if necessary. */
sort: number;
/** Name of the variation; auto-generated by the AI. */
name: string;
…
We prefer having top-level exported "simple functions" to perform operations on DB objects, like converting a Record type to an Array while including the Record key as an "id" property, or formatting the name of a User object, creating a "stub" from a full object, etc. An example building on the above:
export function getTemplateVariations(
variations: TemplateVariations | null | undefined,
): TemplateVariation[] {
return sort(flattenObjectProperty(variations), "sort");
}
Here we use flattenObjectProperty which is a helper function that converts a Record into an Array of the same type but with id added. This way we can pass around TemplateVariation objects without a separate id property, just like our "top level" DB objects.
Any small "helper" function that is designed to work with DB objects and is ALSO isomorphic (preferred) should be placed in the same module as the DB object type. So getUserName() should be in the same users.ts file in packages/db near the export type User = { definition.
Firestore supports "composite indexes" where you can have multiple where clauses in queries that include an inequality filter, like:
app().firestore().collection("users").where("deleted", "==", null).where("admin", "==", true).orderBy("created", "desc")
Because of our orderBy clause, for this query to work, we would need to create a composite index on [deleted, admin, created].
We try to limit our use of composite indexes, because there is a hard limit across the entire project on composite indexes, enforced by Google Cloud Firestore.
We try to centralize our paths in isometric classes with static methods:
packages/db/paths/AppPaths.ts: Contains known paths for packages/app, the user-facing SaaS app.
packages/db/paths/AdminPaths.ts: Contains known paths for packages/admin, our internal-facing admin site.
packages/db/paths/TeamPaths.ts: Contains known paths that are identical between app and admin; we prefer using these because we embed many components from app inside the admin site. This way, those components can use paths from TeamPaths and they will work on both app and admin.
packages/db/paths/SitePaths.ts: Contains known paths for packages/site, our marketing site (currently unused).
packages/db/paths/HttpPaths.ts: Contains known paths for our public-facing API in packages/cloud exported by the http function.
packages/db/paths/StoragePaths.ts: Contains known paths for things we store inside Firebase Storage, mostly images.
When rendering a Link or subclass thereof, we STRONGLY prefer absolute paths constructed using one of these classes, instead of relative paths, for consistency. When adding querystring params to paths, we strongly prefer using our buildPaths helper function in packages/db/urls.ts which serializes params easily and automatically creates links that stay in the local emulator (this is only relevant when making links NOT for <Link/> and friends).