@lpdjs/firestore-repo-service 2.2.9 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,10 +4,489 @@
4
4
  [![npm version](https://img.shields.io/npm/v/@lpdjs/firestore-repo-service?style=for-the-badge)](https://www.npmjs.com/package/@lpdjs/firestore-repo-service)
5
5
  [![License](https://img.shields.io/npm/l/@lpdjs/firestore-repo-service?style=for-the-badge)](https://github.com/solarpush/firestore-repo-service/blob/master/LICENSE)
6
6
 
7
- Type-safe Firestore repository layer with auto-generated query methods, CRUD, and a Firestore→SQL sync pipeline via Pub/Sub + BigQuery.
7
+ Type-safe Firestore repository layer with auto-generated query methods, CRUD,
8
+ a Firestore→SQL sync pipeline via Pub/Sub + BigQuery, change-history tracking,
9
+ and a file-based Hono HTTP server for Firebase Cloud Functions v2.
8
10
 
9
11
  **Full documentation at [frs.lpdjs.fr](https://frs.lpdjs.fr)**
10
12
 
13
+ ## Installation
14
+
15
+ ```bash
16
+ npm install @lpdjs/firestore-repo-service firebase-admin
17
+ ```
18
+
19
+ ---
20
+
21
+ ## Quick start
22
+
23
+ ### Define your models
24
+
25
+ ```typescript
26
+ interface UserModel {
27
+ docId: string;
28
+ email: string;
29
+ name: string;
30
+ age: number;
31
+ isActive: boolean;
32
+ }
33
+
34
+ interface PostModel {
35
+ docId: string;
36
+ userId: string;
37
+ title: string;
38
+ status: "draft" | "published";
39
+ }
40
+ ```
41
+
42
+ ### Create the repository mapping
43
+
44
+ ```typescript
45
+ import {
46
+ createRepositoryConfig,
47
+ buildRepositoryRelations,
48
+ createRepositoryMapping,
49
+ } from "@lpdjs/firestore-repo-service";
50
+ import { doc } from "firebase/firestore";
51
+ import type { Firestore } from "firebase/firestore";
52
+
53
+ const repositoryMapping = {
54
+ users: createRepositoryConfig<UserModel>()({
55
+ path: "users",
56
+ isGroup: false,
57
+ foreignKeys: ["docId", "email"] as const,
58
+ queryKeys: ["name", "isActive"] as const,
59
+ refCb: (db: Firestore, docId: string) => doc(db, "users", docId),
60
+ }),
61
+ posts: createRepositoryConfig<PostModel>()({
62
+ path: "posts",
63
+ isGroup: false,
64
+ foreignKeys: ["docId", "userId"] as const,
65
+ queryKeys: ["status"] as const,
66
+ refCb: (db: Firestore, docId: string) => doc(db, "posts", docId),
67
+ }),
68
+ };
69
+
70
+ // Optional: add relations
71
+ const mappingWithRelations = buildRepositoryRelations(repositoryMapping, {
72
+ posts: {
73
+ userId: { repo: "users", key: "docId", type: "one" as const },
74
+ },
75
+ });
76
+
77
+ export const repos = createRepositoryMapping(db, mappingWithRelations);
78
+ ```
79
+
80
+ ### Use the repositories
81
+
82
+ ```typescript
83
+ // Fetch a single document
84
+ const user = await repos.users.get.byDocId("user123");
85
+ const userByEmail = await repos.users.get.byEmail("john@example.com");
86
+
87
+ // Query multiple documents
88
+ const activeUsers = await repos.users.query.byIsActive(true);
89
+
90
+ // With query options
91
+ const results = await repos.users.query.byIsActive(true, {
92
+ where: [["age", ">=", 18]],
93
+ orderBy: [{ field: "name", direction: "asc" }],
94
+ limit: 50,
95
+ });
96
+
97
+ // Populate a relation
98
+ const post = await repos.posts.get.byDocId("post123");
99
+ if (post) {
100
+ const postWithUser = await repos.posts.populate(post, "userId");
101
+ console.log(postWithUser.populated.users?.name); // type-safe
102
+ }
103
+
104
+ // Update
105
+ const updated = await repos.users.update("user123", { name: "New name", age: 31 });
106
+ ```
107
+
108
+ ## API reference
109
+
110
+ ### `createRepositoryConfig()`
111
+
112
+ | Option | Description |
113
+ | ------------- | ------------------------------------------------------- |
114
+ | `path` | Collection path in Firestore |
115
+ | `isGroup` | `true` for collection group, `false` for simple |
116
+ | `foreignKeys` | Keys for `get.by*` methods (single document lookup) |
117
+ | `queryKeys` | Keys for `query.by*` methods (multi-document query) |
118
+ | `refCb` | Function that returns the document reference |
119
+
120
+ **Sub-collection example:**
121
+
122
+ ```typescript
123
+ comments: createRepositoryConfig<CommentModel>()({
124
+ path: "comments",
125
+ isGroup: true,
126
+ foreignKeys: ["docId"] as const,
127
+ queryKeys: ["postId", "userId"] as const,
128
+ refCb: (db, postId, commentId) =>
129
+ doc(db, "posts", postId, "comments", commentId),
130
+ });
131
+ ```
132
+
133
+ ### Query options
134
+
135
+ ```typescript
136
+ interface QueryOptions<T> {
137
+ where?: [keyof T, WhereFilterOp, any][]; // AND conditions
138
+ orWhere?: [keyof T, WhereFilterOp, any][][]; // OR conditions
139
+ orderBy?: { field: keyof T; direction?: "asc" | "desc" }[];
140
+ limit?: number;
141
+ offset?: number;
142
+ select?: (keyof T)[];
143
+ startAt?: DocumentSnapshot | any[];
144
+ startAfter?: DocumentSnapshot | any[];
145
+ endAt?: DocumentSnapshot | any[];
146
+ endBefore?: DocumentSnapshot | any[];
147
+ }
148
+ ```
149
+
150
+ ### CRUD
151
+
152
+ ```typescript
153
+ // Create (auto-generated ID)
154
+ const newUser = await repos.users.create({ email: "...", name: "...", age: 25, isActive: true });
155
+
156
+ // Set (create or replace)
157
+ await repos.users.set("user123", { ... });
158
+
159
+ // Set with merge
160
+ await repos.users.set("user123", { age: 31 }, { merge: true });
161
+
162
+ // Update (partial)
163
+ await repos.users.update("user123", { age: 32 });
164
+
165
+ // Delete
166
+ await repos.users.delete("user123");
167
+
168
+ // Document ref
169
+ const ref = repos.users.documentRef("user123");
170
+
171
+ // Raw collection ref
172
+ const colRef = repos.users.ref;
173
+ ```
174
+
175
+ ### Batch & Bulk
176
+
177
+ ```typescript
178
+ // Atomic batch (max 500 operations)
179
+ const batch = repos.users.batch.create();
180
+ batch.set(repos.users.documentRef("u1"), { ... });
181
+ batch.update(repos.users.documentRef("u2"), { age: 25 });
182
+ batch.delete(repos.users.documentRef("u3"));
183
+ await batch.commit();
184
+
185
+ // Bulk (auto-split into batches of 500)
186
+ await repos.users.bulk.set([
187
+ { docRef: repos.users.documentRef("u1"), data: { ... }, merge: true },
188
+ ]);
189
+ await repos.users.bulk.update([...]);
190
+ await repos.users.bulk.delete([...]);
191
+ ```
192
+
193
+ ### Real-time listeners
194
+
195
+ ```typescript
196
+ const unsubscribe = repos.users.query.onSnapshot(
197
+ { where: [["isActive", "==", true]] },
198
+ (users) => console.log(users),
199
+ );
200
+ unsubscribe();
201
+ ```
202
+
203
+ ### Pagination
204
+
205
+ ```typescript
206
+ const firstPage = await repos.users.query.by({
207
+ orderBy: [{ field: "createdAt", direction: "desc" }],
208
+ limit: 10,
209
+ });
210
+
211
+ const nextPage = await repos.users.query.by({
212
+ orderBy: [{ field: "createdAt", direction: "desc" }],
213
+ startAfter: firstPage[firstPage.length - 1],
214
+ limit: 10,
215
+ });
216
+
217
+ // Paginate with relations
218
+ const page = await repos.posts.query.paginate({
219
+ pageSize: 10,
220
+ include: [{ relation: "userId", select: ["docId", "name", "email"] }],
221
+ });
222
+ ```
223
+
224
+ ### Aggregations
225
+
226
+ ```typescript
227
+ import { count, sum, average } from "@lpdjs/firestore-repo-service";
228
+
229
+ const activeCount = await repos.users.aggregate.count({ where: [["isActive", "==", true]] });
230
+ const totalViews = await repos.posts.aggregate.sum("views");
231
+ const avgAge = await repos.users.aggregate.average("age");
232
+ ```
233
+
234
+ ### Transactions
235
+
236
+ ```typescript
237
+ const result = await repos.users.transaction.run(async (txn) => {
238
+ const user = await txn.get(repos.users.documentRef("user123"));
239
+ if (user.exists()) {
240
+ txn.update(repos.users.documentRef("user123"), { age: user.data().age + 1 });
241
+ }
242
+ return { success: true };
243
+ });
244
+ ```
245
+
246
+ ### OR queries
247
+
248
+ ```typescript
249
+ // (status = 'active' AND age >= 18) OR (status = 'pending' AND verified = true)
250
+ const users = await repos.users.query.by({
251
+ orWhere: [
252
+ [["status", "==", "active"], ["age", ">=", 18]],
253
+ [["status", "==", "pending"], ["verified", "==", true]],
254
+ ],
255
+ });
256
+ ```
257
+
258
+ ---
259
+
260
+ ## Change History
261
+
262
+ Track every write (create / update / delete) to any Firestore document with
263
+ zero-boilerplate Firestore triggers. Enabled per-repository via the
264
+ `createServers` config.
265
+
266
+ ```typescript
267
+ const servers = createServers(repos, { onRequest, firestoreTriggers });
268
+
269
+ export const { historyTriggers } = servers.history({
270
+ enabled: true,
271
+ repos: { posts: true, users: true },
272
+ // Optional: retention and relational tracking
273
+ relational: true,
274
+ });
275
+ ```
276
+
277
+ Each change is stored as an immutable snapshot in a `__history` sub-collection:
278
+
279
+ ```typescript
280
+ // Read history entries for a document
281
+ const entries = await repos.posts.history.byDocId("post123");
282
+
283
+ // Field-level lookup
284
+ const titleHistory = await repos.posts.history.byField("post123", "title");
285
+ ```
286
+
287
+ Full documentation: [frs.lpdjs.fr/guide/history](https://frs.lpdjs.fr/guide/history)
288
+
289
+ ---
290
+
291
+ ## Hono File-Based API Server
292
+
293
+ A typed, file-based HTTP server built on [Hono](https://hono.dev/), designed
294
+ to ship one Firebase Cloud Function v2 per logical API — with auto-generated
295
+ OpenAPI 3.1, Zod validation, and a CLI scaffolder.
296
+
297
+ ### Install extras
298
+
299
+ ```bash
300
+ npm i hono @hono/node-server
301
+ npm i -D @asteasolutions/zod-to-openapi
302
+ ```
303
+
304
+ ### Bootstrap
305
+
306
+ ```bash
307
+ npx frs-hono init # interactive — creates apis.ts + manifest stub
308
+ npx frs-hono new createPost --domain posts --method post --api v1
309
+ npx frs-hono gen --root src/domains # refresh manifest (run before each build)
310
+ ```
311
+
312
+ ### Configure your APIs (`apis.ts`)
313
+
314
+ ```typescript
315
+ import { createApiRegistry } from "@lpdjs/firestore-repo-service/servers/hono";
316
+
317
+ export const apis = createApiRegistry({
318
+ v1: {
319
+ basePath: "/v1",
320
+ openapi: { info: { title: "Public API", version: "1.0.0" } },
321
+ interceptor: async ({ next, c }) => {
322
+ const data = await next();
323
+ return c.json({ success: true, data, error: null });
324
+ },
325
+ verbose: process.env["NODE_ENV"] !== "production",
326
+ },
327
+ });
328
+
329
+ export const defineRoute = apis.defineRoute;
330
+ ```
331
+
332
+ ### Write a route
333
+
334
+ ```typescript
335
+ // src/domains/posts/useCases/createPost/routes.ts
336
+ import { z } from "zod";
337
+ import { defineRoute } from "../../../../apis.js";
338
+
339
+ export default defineRoute({
340
+ api: "v1", // typed: only registered tags accepted
341
+ method: "post",
342
+ input: z.object({ title: z.string() }),
343
+ output: z.object({ id: z.string() }),
344
+ summary: "Create a post",
345
+ tags: ["posts"],
346
+ handler: async ({ input }) => ({ id: input.title }),
347
+ });
348
+ ```
349
+
350
+ Export an **array** of `defineRoute(...)` to expose the same logic under
351
+ multiple APIs with different schemas — each call infers `input` independently.
352
+
353
+ ### Wire Cloud Functions
354
+
355
+ ```typescript
356
+ // src/index.ts
357
+ import { onRequest } from "firebase-functions/v2/https";
358
+ import { apis } from "./apis.js";
359
+ import { routes } from "./domains/__generated__/routes.js";
360
+
361
+ export const { v1 } = apis.toFunctions(routes, onRequest, {
362
+ defaults: { region: "us-central1", invoker: "public" },
363
+ });
364
+ ```
365
+
366
+ ### Key features
367
+
368
+ | Feature | Details |
369
+ | --- | --- |
370
+ | **File-based routing** | `routes.ts` next to each useCase, scanned at build time |
371
+ | **Multi-API registry** | One Cloud Function per tag, typed `api` field |
372
+ | **Zod validation** | Body / query / path params + optional response validation |
373
+ | **OpenAPI 3.1** | Auto-generated from Zod schemas; Scalar UI at `/docs` |
374
+ | **Interceptor** | Around-style hook for envelopes, error mapping, tracing |
375
+ | **Middlewares** | Per-API and per-route Hono middlewares |
376
+ | **Typed context** | Augment `ContextVariableMap` once, `c.get("user")` typed everywhere |
377
+ | **CLI** | `init` / `new` (interactive) / `gen` |
378
+
379
+ Full documentation: [frs.lpdjs.fr/guide/hono](https://frs.lpdjs.fr/guide/hono)
380
+
381
+ ---
382
+
383
+ ## Servers (admin UI · CRUD REST · Firestore → SQL sync)
384
+
385
+ A single unified factory binds all servers to your repository registry. Per-repo `repo: …` is no longer needed — the registry key drives both the runtime binding and the inferred model type for `fieldsConfig` autocomplete.
386
+
387
+ ```typescript
388
+ import { createServers } from "@lpdjs/firestore-repo-service";
389
+ import { onRequest } from "firebase-functions/v2/https";
390
+ import { BigQueryAdapter } from "@lpdjs/firestore-repo-service/sync/bigquery";
391
+ import { BigQuery } from "@google-cloud/bigquery";
392
+ import { PubSub } from "@google-cloud/pubsub";
393
+ import * as firestoreTriggers from "firebase-functions/v2/firestore";
394
+ import * as pubsubHandler from "firebase-functions/v2/pubsub";
395
+
396
+ const servers = createServers(repos, {
397
+ onRequest,
398
+ httpsOptions: { invoker: "public" },
399
+ });
400
+
401
+ // Admin UI — repo auto-injected from the key, fieldsConfig typed against the model
402
+ export const admin = servers.admin({
403
+ basePath: "/admin",
404
+ auth: { type: "basic", username: "admin", password: "secret" },
405
+ repos: {
406
+ posts: {
407
+ path: "posts",
408
+ fieldsConfig: { title: ["create", "mutable"], status: ["filterable"] },
409
+ allowDelete: true,
410
+ },
411
+ users: { path: "users" },
412
+ },
413
+ });
414
+
415
+ // CRUD REST API
416
+ export const api = servers.crud({
417
+ basePath: "/api",
418
+ repos: {
419
+ posts: { path: "posts", allowDelete: true },
420
+ users: { path: "users" },
421
+ },
422
+ openapi: { title: "My API", version: "1.0.0" },
423
+ });
424
+
425
+ // Firestore → BigQuery sync (triggers + worker + admin Cloud Functions)
426
+ export const { functions } = servers.sync({
427
+ deps: { firestoreTriggers, pubsubHandler, pubsub: new PubSub() },
428
+ adapter: new BigQueryAdapter({
429
+ bigquery: new BigQuery({ projectId: "my-project" }),
430
+ datasetId: "firestore_sync",
431
+ }),
432
+ topicPrefix: "firestore-sync",
433
+ autoMigrate: true,
434
+ admin: {
435
+ auth: { type: "basic", username: "admin", password: "secret" },
436
+ featuresFlag: { healthCheck: true, manualSync: true, viewQueue: true, configCheck: true },
437
+ },
438
+ repos: {
439
+ users: { tableName: "users", columnMap: { docId: "user_id" } },
440
+ posts: { columnMap: { docId: "post_id" } },
441
+ comments: { triggerPath: "posts/{postId}/comments/{docId}" },
442
+ },
443
+ });
444
+
445
+ // Spread Cloud Functions into your exports
446
+ export const {
447
+ users_onCreate, users_onUpdate, users_onDelete, sync_users,
448
+ posts_onCreate, posts_onUpdate, posts_onDelete, sync_posts,
449
+ comments_onCreate, comments_onUpdate, comments_onDelete, sync_comments,
450
+ adminsync,
451
+ } = functions;
452
+ ```
453
+
454
+ When `onRequest` is passed to `createServers`, `servers.admin()` and `servers.crud()` return ready-to-export Cloud Functions. Without it, they return raw HTTP handlers you can wrap yourself.
455
+
456
+ The sync admin endpoint (`/`) exposes a UI for health checks, force-sync, queue inspection, and GCP config verification.
457
+
458
+ For a custom SQL backend, implement the `SqlAdapter` interface:
459
+
460
+ ```typescript
461
+ import type { SqlAdapter } from "@lpdjs/firestore-repo-service/sync";
462
+
463
+ class MyAdapter implements SqlAdapter {
464
+ // tableExists, getTableColumns, createTable, upsertRows, deleteRows, executeRaw
465
+ }
466
+ ```
467
+
468
+ Full sync documentation: [frs.lpdjs.fr/guide/sync](https://frs.lpdjs.fr/guide/sync)
469
+
470
+ ---
471
+
472
+ ## Testing
473
+
474
+ ```bash
475
+ # Run emulator + tests (watch mode)
476
+ bun run test:watch
477
+
478
+ # Two-terminal alternative
479
+ bun run emulator # terminal 1
480
+ bun run test # terminal 2
481
+ ```
482
+
483
+ Firestore emulator runs on `localhost:8080`, UI on `http://localhost:4000`.
484
+
485
+ ## License
486
+
487
+ MIT
488
+
489
+
11
490
  ## Installation
12
491
 
13
492
  ```bash