npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

firestore-converter

v0.0.14

Published

Share Firestore Data Converters between Client and Server Firebase SDK

Downloads

42

Readme

firestore-converter

Google Firestore has a cool and useful 'data converter' system that provides a way to define transforms to be used to translate entities between the version persisted to the Firestore database and your applications in-memory object model of it.

Unfortunately, it also has both a client-side firebase SDK and a server-side firebase-admin SDK, which of course have incompatible FirestoreDataConverter interfaces for defining these transforms. This makes defining them awkward.

Instead of using the Uint8Array for binary data on both the server and client, the SDKs use Buffer on the server and a custom Bytes object on the client. Both require separate handling.

Dates fare a little better, in that they both use a Timestamp field, but you need to translate between regular JavaScript Date objects (couldn't they have been used, and an extra property added to enable round-tripping with the increased precision?)

Anyway, the point is that the Firestore Data Converter idea is great, but the implentation is a little awkward to use and it's too difficult to support both server and client with one codebase. If only there was a way to have a single implementation, shared between both server and client?!

That's what this lib is intended to help with ...

Usage

The code examples show SvelteKit features to reference environment variables, but the approach should be usable with other web frameworks too. It also has a useful naming convention where anything ending in .server is automatically blocked from being accidentally referenced by, and bundled into, client-side code. We've used the same convention in our module naming to benefit from it.

Installation

Install using your package manager of choice (which should really be pnpm):

pnpm i -D firestore-converter

Provided Types

Import the types from the 'firestore-converter' package that will allow you to define you object model, DB model and converter:

import type {
  FirestoreDataConverter,
  WithFieldValue,
  DocumentData,
  QueryDocumentSnapshot,
  Binary,
  Timestamp,
  Adapter
} from 'firestore-converter';

Some of these types are just to make it convenient and easy to migrate existing data converter code you may have and avoid having to decide whether you should be importing them from the firebase/firestore package or firebase-admin/firestore, others help with making your converter independent of each specific client-side or server-side SDK:

  • FirestoreDataConverter
  • WithFieldValue
  • DocumentData
  • QueryDocumentSnapshot

The Binary type provides a consistent way to represent binary data in the database model instead of having to deal with Buffer (in the firebase-admin Server SDK) vs Bytes (in the firebase Client SDK).

The Timestamp likewise represents Firestore Timestamp data in a consistent way, to make it easy to use the conversion functions to translate to and from regular JavaScript Date objects.

Finally, the Adapter interface acts as an adapter between the two Firebase SDKs and provides access to several functions to transform between the various Timestamp and Binary representations and regular JavaScript Date Objects, Uint8array typed arrays, and Base64 or Hex encoded strings. It also provides an SDK agnostic way to create the sentinel fields used to update arrays, delete fields and set server timestamps.

Object Model and DB Model

Using these types, we can define our object models (how data is represented to our app) and a corresponding DB model (how data is stored in Firestore). These don't have to match 1:1, and you can even utilize union types and migration function to handle schema versioning.

For this example though, we'll keep things simple to focus on the type conversions required:

First, the in-memory object model. Note that photo is a binary value but we want to use it as a Base64 string:

export interface Person {
  id: string;
  name: string;
  dob: Date;
  photo: string; // base 64 string
}

The DB model doesn't include the id field (which is in the document ref) and stores the dob Date field as a Timestamp and the photo Base64 string as Binary:

export interface DBPerson {
  name: string;
  dob: Timestamp;
  photo: Binary;
}

Data Converter Class

Now we define our converter class. This will implement the FirestoreDataConverter<Model, DBModel> interface, and accept an instance of the Adapter in the constructor. The toFirestore method will convert from the in memory object model to the DB model, and the fromFirestore method in the opposite direction. Each can make use of the provided Adapter instance methods to transform the appropriate fields.

export class PersonConverter implements FirestoreDataConverter<Person, DBPerson> {
  constructor(private readonly adapter: Adapter) {}

  toFirestore(modelObject: WithFieldValue<Person>): WithFieldValue<DBPerson> {
    return {
      name: modelObject.name,
      dob: this.adapter.fromDate(modelObject.dob as Date),
      photo: this.adapter.fromBase64String(modelObject.photo as string)
    };
  }

  fromFirestore(snapshot: QueryDocumentSnapshot<DocumentData, DocumentData>): Person {
    const person = snapshot.data() as DBPerson;
    return {
      id: snapshot.id,
      name: person.name,
      dob: this.adapter.toDate(person.dob),
      photo: this.adapter.toBase64String(person.photo)
    };
  }
}

You don't have to declare the DB Model though, you can omit it which will cause it to use the DocumentData type in it's place so you only need to define the applications in memory object model.

Likewise you may not want to use the WithFieldValue<Model> in the toFirestore method which is only required if you'll be making use of FieldValue sentinel types, but require you to cast the model types as in the example above.

Adapter Methods

The Adapter instance passed in to your Data Converter class provides the following methods all from the perspective of the Applications Object Model. You'll typically be using the from... methods in the toFirestore method (from Object Model, to DB Model) and the to... methods in the fromFirestore method (to Object Model, from DB Model). Personally, I would have make the 'to' and 'from' being to and from the database formats, but the firebase SDKs already used this opposite naming so I've aligned with that to hopefully avoid confusion.

| Method | Description | | ----------------------------------------------- | ---------------------------------------------------- | | fromBase64String(value: string): Binary | Store a Base64 encoded string as a binary field | | fromUint8Array(value: Uint8Array): Binary | Store a typed Uint8Array as a binary field | | fromHexString(value: string): Binary | Store a hex encoded string as a binary field | | fromString(value: string): Binary | Store a unicode string as a binary field | | fromDate(value: Date): Timestamp | Store a JavaScript Date object as a Timestamp | | toBase64String(value: Binary): string | Convert a binary field to a Base64 encoded string | | toUInt8Array(value: Binary): Uint8Array | Convert a binary field to a typed Uint8Array | | toHexString(value: Binary): string | Convert a binary field to a hex encoded string | | toString(value: Binary): string | Convert from a binary field to a unicode string | | toDate(value: Timestamp): Date | Convert from a Timestamp to a JavaScript Date object | | isBinary(value: any): boolean | Tests whether a field is a Binary value | | isTimestamp(value: any): boolean | Tests whether a field is a Timestamp value | | arrayRemove(...elements: any[]): FieldValue | Returns a sentinel value to remove array elements | | arrayUnion(...elements: any[]): FieldValue | Returns a sentinel value to union array elements | | delete() | Returns a sentinel value to delete a field | | increment(n: number): FieldValue | Returns a sentinel value to increment a field | | serverTimestamp(): FieldValue | Returns a sentinel value to set a server timestamp |

Firebase Clients

The converter class we've defined can now be used from both the Server and the Client SDK. This is done by importing the appropriate createConverter function from each and passing the PersonConverter constructor to it to create an instance. This will handle the different field type conversions required.

firebase.server

Here is an example of creating a Firestore client on the server, using the firebase-admin SDK, and then using the PersonConverter. Note the import of the converter from firestore-converter/firebase.server. This is designed to handle the server representation of Firestore data.

import { cert, initializeApp } from 'firebase-admin/app';
import { SERVICE_ACCOUNT_FILE } from '$env/static/private';
import { getFirestore } from 'firebase-admin/firestore';
import { PersonConverter, type Person } from './person';
import { createConverter } from 'firestore-converter/firebase.server';

const app = initializeApp({ credential: cert(SERVICE_ACCOUNT_FILE) });

const firestore = getFirestore(app);

const personConverter = createConverter(PersonConverter);

export async function getPeople() {
  const col = firestore.collection('people').withConverter(personConverter);
  const snap = await col.get();
  const people = snap.docs.map((doc) => doc.data());
  return people;
}

firebase

The firestore client on the browser, using the firebase SDK is similar. But we now import the converter from firestore-converter/firebase instead. This knows how to handle the client representation of Firestore data.

import { initializeApp } from 'firebase/app';
import {
  PUBLIC_API_KEY,
  PUBLIC_AUTH_DOMAIN,
  PUBLIC_DATABASE_URL,
  PUBLIC_PROJECT_ID,
  PUBLIC_STORAGE_BUCKET,
  PUBLIC_MESSAGE_SENDER_ID,
  PUBLIC_APP_ID,
  PUBLIC_MEASUREMENT_ID
} from '$env/static/public';
import { getFirestore, collection, doc, getDoc, setDoc, getDocs } from 'firebase/firestore';
import { PersonConverter, type Person } from './person';
import { createConverter } from 'firestore-converter/firebase';

const app = initializeApp({
  apiKey: PUBLIC_API_KEY,
  authDomain: PUBLIC_AUTH_DOMAIN,
  databaseURL: PUBLIC_DATABASE_URL,
  projectId: PUBLIC_PROJECT_ID,
  storageBucket: PUBLIC_STORAGE_BUCKET,
  messagingSenderId: PUBLIC_MESSAGE_SENDER_ID,
  appId: PUBLIC_APP_ID,
  measurementId: PUBLIC_MEASUREMENT_ID
});

const firestore = getFirestore(app);

const personConverter = createConverter(PersonConverter);

export async function getPeople() {
  const col = collection(firestore, 'people').withConverter(personConverter);
  const snap = await getDocs(col);
  const people = snap.docs.map((doc) => doc.data());
  return people;
}

Result

We can now load and save data easily from both client and server, using a single shared definition of your data converter classes.

Default Converters

We've provided a DefaultConverter for both Client and Admin that will automatically convert any Uint8Array types in your model to and from Firestore Binary types, and JavaScript Date objects to and from Firestore Timestamp fields. It will iterate all nested objects and arrays (including objects inside arrays) so is convenient but might be less performant than a manually implemented converter if you have a very large object model with only a few properties that need converting.

DefaultConverter accepts an optional object paramater with the following options:

handle_id: boolean (default true) - whether to remove and restore an objects id property (which should be a string) when saving and loading the object. This makes it convenient to have objects with their Firestore ID as a property, without duplicating the ID in the stored data itself. If you don't include the id in the object or want it persisted for some reason, set this to false.

transform: (id: string) => string (default id => id) - a transform to apply to the id when restoring it (after reading from Firestore). If you need to encode the id when saving to Firestore, for example using encodeURIComponent to allow a page slug to be used as a document ID (which would require special characters such as / be encoded to %2F for compatibility with Firestore) then you would set the transform to be decodeURIComponent. The id encoding is handled outside of the DataConverter due to the design of the Firebase SDKs.

Examples of using the DefaultConverter options:

interface Order {
  name: string;
  email: string;
  ordered: Date;
  address: Address;
  lines: OrderLines[];
}

interface Page {
  id: string;
  markdown: string;
  html: string;
  tags: string[];
  created: Date;
  published: Date | null;
  thumbnail: Uint8Array;
}

const orderConverter = new DefaultConverter<Order>({ handle_id: false });
const pageConverter = new DefaultConverter<Page>({ transform: decodeURIComponent });

Because it is imported from the appropriate firestore-converter/firebase or firestore-converter/firebase.server module, there is no need to pass in the corresponding Adapter implementation that would also be imported from the same modules - it will automatically use the corresponding one.

firebase.server

Within your server code, you would use:

import { DefaultConverter } from 'firestore-converter/firebase.server';
import { type Person } from './person';

const personConverter = new DefaultConverter<Person>();

firebase

Within the client code, you would use:

import { DefaultConverter } from 'firestore-converter/firebase';
import { type Person } from './person';

const personConverter = new DefaultConverter<Person>();