@firestore-repository/firebase-js-sdk
v0.5.0
Published
A minimum and universal Firestore ORM (Repository Pattern) for TypeScript
Downloads
290
Maintainers
Readme
firestore-repository
A minimal and universal Firestore client (Repository Pattern) for TypeScript
Features
- 🚀 Minimal: Only a few straightforward interfaces and classes. You can start using it immediately without a steep learning curve.
- 🌐 Universal: You can share most code, including schema and query definitions, between backend and frontend.
- 🤝 Unopinionated: This library does not introduce any additional concepts, and respects the vocabulary of the official Firestore client library.
- ✅ Type-safe: This library provides a type-safe interface. It also covers the untyped parts of the official Firestore library.
- 🗄️ Repository Pattern: A simple and consistent way to access Firestore data.
Installation
For backend (with @google-cloud/firestore)
npm install firestore-repository @firestore-repository/google-cloud-firestoreFor web frontend (with @firebase/firestore)
npm install firestore-repository @firestore-repository/firebase-js-sdkUsage
Define a collection and its repository
import {
rootCollection,
string,
double,
map,
optional,
literal,
array,
} from 'firestore-repository/schema';
// For backend
import { Firestore } from '@google-cloud/firestore';
import { rootCollectionRepository } from '@firestore-repository/google-cloud-firestore';
const db = new Firestore();
// For web frontend
import { getFirestore } from '@firebase/firestore';
import { rootCollectionRepository } from '@firestore-repository/firebase-js-sdk';
const db = getFirestore();
// define a collection
const users = rootCollection({
name: 'Users',
schema: {
name: string(),
profile: map({ age: double(), gender: optional(literal('male', 'female')) }),
tag: array(string()),
},
});
const repository = rootCollectionRepository(db, users);Basic operations for a single document
All operations are type-safe based on the schema you defined. The data field is typed according to your schema, so invalid data structures are caught at compile time.
// Set a document
await repository.set({
ref: 'user1',
data: { name: 'John Doe', profile: { age: 42, gender: 'male' }, tag: ['new'] },
});
// Create a document (backend only)
await repository.create({
ref: 'user2',
data: { name: 'Charlie', profile: { age: 25, gender: 'male' }, tag: [] },
});
// Get a document
const doc = await repository.get('user1');
// Listen to a document
repository.getOnSnapshot('user1', (doc) => {
console.log(doc);
});
// Delete a document
await repository.delete('user2');Query
Field paths in query conditions are automatically derived from the schema type, not just plain strings — so typos and invalid paths are caught at compile time. The filter value is also type-checked based on the field type and operator (e.g., array-contains expects an element type of the array field).
import { eq, gte, limit, query, where } from 'firestore-repository/query';
import { average, count, sum } from 'firestore-repository/aggregate';
// Define a query
// Field paths like 'profile.age' are auto-completed and type-checked against the schema.
// The value `20` is validated as `number` because `profile.age` is `number`.
const q = query(
{ collection: users },
where(gte('profile.age', 20), eq('profile.gender', 'male')),
// where(gte('profile.age', 'foo')) // ← Compile error: string is not assignable to number
// where(eq('nonExistent', 1)) // ← Compile error: invalid field path
limit(10),
);
// List documents
const docs = await repository.list(q);
// Listen to documents
repository.listOnSnapshot(q, (docs) => {
console.log(docs);
});
// Aggregate
const result = await repository.aggregate(q, {
avgAge: average('profile.age'),
sumAge: sum('profile.age'),
count: count(),
});
console.log(`avg:${result.avgAge} sum:${result.sumAge} count:${result.count}`);Batch operations
// Get multiple documents (backend only)
const users = await repository.batchGet(['user1', 'user2']);
// Set multiple documents
await repository.batchSet([
{ ref: 'user1', data: { name: 'Alice', profile: { age: 30, gender: 'female' }, tag: ['new'] } },
{ ref: 'user2', data: { name: 'Bob', profile: { age: 20, gender: 'male' }, tag: [] } },
]);
// Delete multiple documents
await repository.batchDelete(['user1', 'user2']);Include multiple different operations in a batch
// For backend
const batch = db.batch();
// For web frontend
import { writeBatch } from '@firebase/firestore';
const batch = writeBatch(db);
await repository.set(
{ ref: 'user3', data: { name: 'Bob', profile: { age: 20, gender: 'male' }, tag: [] } },
{ tx: batch },
);
await repository.batchSet(
[
/* ... */
],
{ tx: batch },
);
await repository.delete('user4', { tx: batch });
await repository.batchDelete(['user5', 'user6'], { tx: batch });
await batch.commit();Transaction
// For web frontend
import { runTransaction } from '@firebase/firestore';
// Or use db.runTransaction for backend
await runTransaction(db, async (tx) => {
// Get
const doc = await repository.get('user1', { tx });
if (doc) {
doc.data.tag = [...doc.data.tag, 'new-tag'];
// Set
await repository.set(doc, { tx });
await repository.batchSet(
[
{ ...doc, ref: 'user2' },
{ ...doc, ref: 'user3' },
],
{ tx },
);
}
// Delete
await repository.delete('user4', { tx });
await repository.batchDelete(['user5', 'user6'], { tx });
});Subcollection
Subcollections are defined with subCollection, specifying the parent collection path. The only difference from root collections is that the document ref becomes a tuple (array of parent doc ID + doc ID). All other operations (query, batch, transaction, etc.) work the same.
import { subCollection, string } from 'firestore-repository/schema';
// For backend
import { subcollectionRepository } from '@firestore-repository/google-cloud-firestore';
// For web frontend
import { subcollectionRepository } from '@firestore-repository/firebase-js-sdk';
const posts = subCollection({
name: 'Posts',
schema: { title: string() },
parent: ['Users'] as const,
});
const postRepository = subcollectionRepository(db, posts);
// Set a document (ref is [parentDocId, docId])
await postRepository.set({ ref: ['user1', 'post1'], data: { title: 'My first post' } });
// Get a document
const post = await postRepository.get(['user1', 'post1']);Custom Mapper
By default, rootCollectionRepository returns a repository with { ref: string, data: ... } as its model type. If you want to use your own application model types, you can define a custom Mapper and use repositoryWithMapper to create a repository that automatically converts between Firestore documents and your models.
A Mapper consists of three functions:
toDocRef: Converts your model's ID to a Firestore document referencefromFirestore: Converts a Firestore document to your read modeltoFirestore: Converts your write model to a Firestore document
You can also define different types for reading and writing via AppModel<Id, Read, Write> (e.g., omitting server-managed fields from the write type).
import { type AppModel, type Mapper } from 'firestore-repository/repository';
// For backend
import { repositoryWithMapper } from '@firestore-repository/google-cloud-firestore';
// For web frontend
import { repositoryWithMapper } from '@firestore-repository/firebase-js-sdk';
// Define your application model type
type User = {
id: string;
name: string;
profile: { age: number; gender?: 'male' | 'female' };
tag: string[];
};
// Define a mapper
const userMapper: Mapper<typeof users, AppModel<string, User, User>> = {
toDocRef: (id) => [id],
fromFirestore: (doc) => ({ id: doc.ref[0], ...doc.data }),
toFirestore: (user) => ({
ref: [user.id],
data: { name: user.name, profile: user.profile, tag: user.tag },
}),
};
const repository = repositoryWithMapper(db, users, userMapper);
// Now the repository accepts and returns your custom User type directly
await repository.set({
id: 'user1',
name: 'Alice',
profile: { age: 30, gender: 'female' },
tag: ['new'],
});
const user: User | undefined = await repository.get('user1');
await repository.delete('user1');