🔥 Firestore Repository Service

Un service de repository type-safe pour Firestore avec génération automatique des méthodes de requête et CRUD.
📚 Documentation complète disponible sur frs.lpdjs.fr
✨ Fonctionnalités
- 🎯 Type-safe : TypeScript avec inférence complète des types
- 🚀 Auto-génération : Méthodes
get.by* et query.by* générées automatiquement
- 🔍 Requêtes avancées : Support des conditions OR, tri, pagination avec curseurs
- 📦 Opérations en masse : Batch et bulk operations
- 🏗️ Collections et sous-collections : Support complet
- 💡 API intuitive : Accesseurs directs via getters
- 📡 Real-time : Listeners
onSnapshot pour les mises à jour en temps réel
- 🔢 Agrégations : Count, sum, average avec support serveur
- ✏️ CRUD complet : Create, set, update, delete avec types préservés
- 🔄 Transactions : Opérations transactionnelles type-safe
- 🔗 Relations : Populate avec select typé (champs projetés)
- 📄 Pagination : Curseurs, include avec relations, select
📦 Installation
npm install @lpdjs/firestore-repo-service firebase-admin
yarn add @lpdjs/firestore-repo-service firebase-admin
bun add @lpdjs/firestore-repo-service firebase-admin
🚀 Démarrage rapide
1. Définir vos modèles
interface UserModel {
docId: string;
email: string;
name: string;
age: number;
isActive: boolean;
}
interface PostModel {
docId: string;
userId: string;
title: string;
status: "draft" | "published";
}
2. Créer votre mapping
import {
createRepositoryConfig,
buildRepositoryRelations,
createRepositoryMapping,
} from "@lpdjs/firestore-repo-service";
import { doc } from "firebase/firestore";
import type { Firestore } from "firebase/firestore";
const repositoryMapping = {
users: createRepositoryConfig<UserModel>()({
path: "users",
isGroup: false,
foreignKeys: ["docId", "email"] as const,
queryKeys: ["name", "isActive"] as const,
refCb: (db: Firestore, docId: string) => doc(db, "users", docId),
}),
posts: createRepositoryConfig<PostModel>()({
path: "posts",
isGroup: false,
foreignKeys: ["docId", "userId"] as const,
queryKeys: ["status"] as const,
refCb: (db: Firestore, docId: string) => doc(db, "posts", docId),
}),
};
const mappingWithRelations = buildRepositoryRelations(repositoryMapping, {
posts: {
userId: { repo: "users", key: "docId", type: "one" as const },
},
});
export const repos = createRepositoryMapping(db, mappingWithRelations);
3. Utiliser les repositories
const user = await repos.users.get.byDocId("user123");
const userByEmail = await repos.users.get.byEmail("john@example.com");
const activeUsers = await repos.users.query.byIsActive(true);
const post = await repos.posts.get.byDocId("post123");
if (post) {
const postWithUser = await repos.posts.populate(post, "userId");
console.log(postWithUser.populated.users?.name);
}
const filteredUsers = await repos.users.query.byName("John", {
where: [["age", ">=", 18]],
orderBy: [{ field: "createdAt", direction: "desc" }],
limit: 10,
});
const updated = await repos.users.update("user123", {
name: "John Updated",
age: 31,
});
📚 Guide complet
Configuration
createRepositoryConfig()
Configure un repository avec ses clés et méthodes.
Paramètres :
path : Chemin de la collection dans Firestore
isGroup : true pour une collection group, false pour une collection simple
foreignKeys : Clés pour les méthodes get.by* (recherche unique)
queryKeys : Clés pour les méthodes query.by* (recherche multiple)
type : Type TypeScript du modèle
refCb : Fonction pour créer la référence du document
Exemple collection simple :
users: createRepositoryConfig<UserModel>()({
path: "users",
isGroup: false,
foreignKeys: ["docId", "email"] as const,
queryKeys: ["isActive", "role"] as const,
refCb: (db: Firestore, docId: string) => doc(db, "users", docId),
});
Exemple sous-collection :
comments: createRepositoryConfig<CommentModel>()({
path: "comments",
isGroup: true,
foreignKeys: ["docId"] as const,
queryKeys: ["postId", "userId"] as const,
refCb: (db: Firestore, postId: string, commentId: string) =>
doc(db, "posts", postId, "comments", commentId),
});
Méthodes GET
Récupère un document unique par une clé étrangère.
const user = await repos.users.get.byDocId("user123");
const userByEmail = await repos.users.get.byEmail("john@example.com");
const result = await repos.users.get.byDocId("user123", true);
if (result) {
console.log(result.data);
console.log(result.doc);
}
const users = await repos.users.get.byList("docId", [
"user1",
"user2",
"user3",
]);
Méthodes QUERY
Recherche plusieurs documents par une clé de requête.
const activeUsers = await repos.users.query.byIsActive(true);
const usersByName = await repos.users.query.byName("John");
const results = await repos.users.query.byIsActive(true, {
where: [["age", ">=", 18]],
orderBy: [{ field: "name", direction: "asc" }],
limit: 50,
});
const users = await repos.users.query.by({
where: [
["isActive", "==", true],
["age", ">=", 18],
],
orderBy: [{ field: "createdAt", direction: "desc" }],
limit: 10,
});
const posts = await repos.posts.query.by({
orWhere: [[["status", "==", "published"]], [["status", "==", "draft"]]],
});
Options de requête
type WhereClause<T> = [keyof T, WhereFilterOp, any];
interface QueryOptions<T> {
where?: WhereClause<T>[];
orWhere?: WhereClause<T>[][];
orderBy?: {
field: keyof T;
direction?: "asc" | "desc";
}[];
limit?: number;
offset?: number;
select?: (keyof T)[];
startAt?: DocumentSnapshot | any[];
startAfter?: DocumentSnapshot | any[];
endAt?: DocumentSnapshot | any[];
endBefore?: DocumentSnapshot | any[];
}
Mise à jour
const updated = await repos.users.update("user123", {
name: "New Name",
age: 30,
});
const updatedComment = await repos.comments.update(
"post123",
"comment456",
{ text: "Updated text" }
);
Références de documents
const userRef = repos.users.documentRef("user123");
const commentRef = repos.comments.documentRef("post123", "comment456");
Opérations Batch
Pour des opérations atomiques (max 500 opérations).
const batch = repos.users.batch.create();
batch.set(repos.users.documentRef("user1"), {
name: "User One",
email: "user1@example.com",
});
batch.update(repos.users.documentRef("user2"), {
age: 25,
});
batch.delete(repos.users.documentRef("user3"));
await batch.commit();
Opérations Bulk
Pour traiter de grandes quantités (automatiquement divisées en batches de 500).
await repos.users.bulk.set([
{
docRef: repos.users.documentRef("user1"),
data: { name: "User 1", email: "user1@example.com" },
merge: true,
},
{
docRef: repos.users.documentRef("user2"),
data: { name: "User 2", email: "user2@example.com" },
},
]);
await repos.users.bulk.update([
{ docRef: repos.users.documentRef("user1"), data: { age: 30 } },
{ docRef: repos.users.documentRef("user2"), data: { age: 25 } },
]);
await repos.users.bulk.delete([
repos.users.documentRef("user1"),
repos.users.documentRef("user2"),
]);
Récupérer tous les documents
const allUsers = await repos.users.query.getAll();
const filteredUsers = await repos.users.query.getAll({
where: [["isActive", "==", true]],
orderBy: [{ field: "createdAt", direction: "desc" }],
limit: 100,
});
const userNames = await repos.users.query.getAll({
select: ["docId", "name", "email"],
});
Real-time listeners (onSnapshot)
const unsubscribe = repos.users.query.onSnapshot(
{
where: [["isActive", "==", true]],
orderBy: [{ field: "name", direction: "asc" }],
},
(users) => {
console.log("Données mises à jour:", users);
},
(error) => {
console.error("Erreur:", error);
}
);
unsubscribe();
La pagination basée sur les curseurs est plus efficace que offset pour de grandes collections.
const firstPage = await repos.users.query.by({
orderBy: [{ field: "createdAt", direction: "desc" }],
limit: 10,
});
const lastDoc = firstPage[firstPage.length - 1];
const nextPage = await repos.users.query.by({
orderBy: [{ field: "createdAt", direction: "desc" }],
startAfter: lastDoc,
limit: 10,
});
const page = await repos.users.query.by({
orderBy: [{ field: "createdAt", direction: "desc" }],
startAfter: [new Date("2024-01-01")],
limit: 10,
});
CRUD complet
const newUser = await repos.users.create({
email: "new@example.com",
name: "New User",
age: 25,
isActive: true,
});
console.log(newUser.docId);
await repos.users.set("user123", {
email: "user@example.com",
name: "User",
age: 30,
isActive: true,
});
await repos.users.set(
"user123",
{ age: 31 },
{ merge: true }
);
const updated = await repos.users.update("user123", {
age: 32,
name: "Updated Name",
});
await repos.users.delete("user123");
Transactions
const result = await repos.users.transaction.run(async (txn) => {
const user = await txn.get(repos.users.documentRef("user123"));
if (user.exists()) {
const userData = user.data();
txn.update(repos.users.documentRef("user123"), {
age: userData.age + 1,
});
txn.set(repos.users.documentRef("user124"), {
email: "new@example.com",
name: "New User",
});
txn.delete(repos.users.documentRef("user125"));
}
return { success: true };
});
await repos.users.transaction.run(async (txn) => {
const rawTransaction = txn.raw;
});
Agrégations
import { count, sum, average } from "@lpdjs/firestore-repo-service";
const activeCount = await repos.users.aggregate.count({
where: [["isActive", "==", true]],
});
const totalViews = await repos.posts.aggregate.sum("views");
const avgAge = await repos.users.aggregate.average("age");
const publishedViews = await repos.posts.aggregate.sum("views", {
where: [["status", "==", "published"]],
});
Accès à la collection Firestore
const collectionRef = repos.users.ref;
🎯 Exemples avancés
Collection imbriquée complexe
const repositoryMapping = {
eventRatings: createRepositoryConfig<RatingModel>()({
path: "ratings",
isGroup: true,
foreignKeys: ["docId"] as const,
queryKeys: ["eventId", "rating"] as const,
refCb: (
db: Firestore,
residenceId: string,
eventId: string,
ratingId: string
) =>
doc(
db,
"residences",
residenceId,
"events",
eventId,
"ratings",
ratingId
),
}),
};
const rating = await repos.eventRatings.update(
"residence123",
"event456",
"rating789",
{ score: 5 }
);
Recherche complexe avec OR
const users = await repos.users.query.by({
orWhere: [
[
["status", "==", "active"],
["age", ">=", 18],
],
[
["status", "==", "pending"],
["verified", "==", true],
],
],
orderBy: [{ field: "createdAt", direction: "desc" }],
limit: 100,
});
Populate avec select (projection)
const postWithUser = await repos.posts.populate(post, "userId");
console.log(postWithUser.populated.users?.name);
const userWithPosts = await repos.users.populate(
{ docId: user.docId },
{
relation: "docId",
select: ["docId", "title", "status"],
}
);
const postWithAll = await repos.posts.populate(post, ["userId", "categoryId"]);
const page = await repos.posts.query.paginate({
pageSize: 10,
orderBy: [{ field: "createdAt", direction: "desc" }],
include: ["userId"],
});
const pageWithSelect = await repos.posts.query.paginate({
pageSize: 10,
include: [{ relation: "userId", select: ["docId", "name", "email"] }],
});
for (const post of page.data) {
console.log(post.title);
console.log(post.populated.users?.name);
}
🔧 Types exportés
import type {
WhereClause,
QueryOptions,
RepositoryConfig,
RelationConfig,
PopulateOptions,
PopulateOptionsTyped,
IncludeConfig,
IncludeConfigTyped,
PaginationOptions,
PaginationResult,
PaginationWithIncludeOptions,
PaginationWithIncludeOptionsTyped,
} from "@lpdjs/firestore-repo-service";
🧪 Tests avec l'émulateur
Pour tester rapidement sans projet Firebase :
npm install -g firebase-tools
bun run test:watch
bun run emulator
bun run test
L'émulateur Firestore démarre sur localhost:8080 avec une UI sur http://localhost:4000.
Voir test/README.md pour plus de détails.
📝 Licence
MIT
🤝 Contribution
Les contributions sont les bienvenues ! N'hésitez pas à ouvrir une issue ou une pull request.
📬 Support
Pour toute question ou problème, ouvrez une issue sur GitHub.