@lpdjs/firestore-repo-service 2.6.16 → 2.6.17

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.
@@ -0,0 +1,2737 @@
1
+ # @lpdjs/firestore-repo-service (v2.6.17) — full documentation
2
+
3
+ > ⚡ Type-safe Firestore ORM with auto-generated repositories, advanced queries, relations with typed select, pagination with include, batch/bulk operations, aggregations and transactions.
4
+
5
+ Concatenated guide documentation for LLM ingestion. Generated from the VitePress docs.
6
+
7
+ ---
8
+
9
+ # Getting Started
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ npm install @lpdjs/firestore-repo-service firebase-admin
15
+ # or
16
+ bun add @lpdjs/firestore-repo-service firebase-admin
17
+ ```
18
+
19
+ ## Quick Start
20
+
21
+ ### 1. Define your models with Zod (recommended)
22
+
23
+ Using Zod gives you automatic schema inference — no need to declare the model interface separately.
24
+
25
+ ```typescript
26
+ import z from "zod";
27
+
28
+ const userSchema = z.object({
29
+ docId: z.string(),
30
+ documentPath: z.string(),
31
+ email: z.string(),
32
+ name: z.string().nullable(),
33
+ age: z.number(),
34
+ isActive: z.boolean().nullable(),
35
+ createdAt: z.date(),
36
+ updatedAt: z.date(),
37
+ });
38
+
39
+ const postSchema = z.object({
40
+ docId: z.string(),
41
+ documentPath: z.string(),
42
+ userId: z.string(),
43
+ title: z.string(),
44
+ content: z.string(),
45
+ status: z.enum(["draft", "published"]),
46
+ createdAt: z.date(),
47
+ updatedAt: z.date(),
48
+ });
49
+ ```
50
+
51
+ **Without Zod**
52
+ Pass the TypeScript interface as generic and omit the schema argument:
53
+ ```typescript
54
+ const users = createRepositoryConfig<UserModel>()({ /* config */ });
55
+ ```
56
+
57
+ ### 2. Create your repository mapping
58
+
59
+ ```typescript
60
+ import {
61
+ createRepositoryConfig,
62
+ buildRepositoryRelations,
63
+ createRepositoryMapping,
64
+ } from "@lpdjs/firestore-repo-service";
65
+ import { initializeApp } from "firebase-admin/app";
66
+ import { getFirestore, Firestore } from "firebase-admin/firestore";
67
+
68
+ initializeApp();
69
+
70
+ // Step 1 — base config
71
+ const repositoryMapping = {
72
+ users: createRepositoryConfig(userSchema)({
73
+ path: "users",
74
+ isGroup: false,
75
+ foreignKeys: ["docId", "email"] as const,
76
+ queryKeys: ["name", "isActive"] as const,
77
+ documentKey: "docId",
78
+ pathKey: "documentPath",
79
+ createdKey: "createdAt",
80
+ updatedKey: "updatedAt",
81
+ refCb: (db: Firestore, docId: string) => db.collection("users").doc(docId),
82
+ }),
83
+
84
+ posts: createRepositoryConfig(postSchema)({
85
+ path: "posts",
86
+ isGroup: false,
87
+ foreignKeys: ["docId", "userId"] as const,
88
+ queryKeys: ["status", "userId"] as const,
89
+ documentKey: "docId",
90
+ pathKey: "documentPath",
91
+ createdKey: "createdAt",
92
+ updatedKey: "updatedAt",
93
+ refCb: (db: Firestore, docId: string) => db.collection("posts").doc(docId),
94
+ }),
95
+ };
96
+
97
+ // Step 2 — relations (optional)
98
+ const mappingWithRelations = buildRepositoryRelations(repositoryMapping, {
99
+ users: { docId: { repo: "posts", key: "userId", type: "many" as const } },
100
+ posts: { userId: { repo: "users", key: "docId", type: "one" as const } },
101
+ });
102
+
103
+ // Step 3 — create the service (db resolved lazily, after initializeApp)
104
+ export const repos = createRepositoryMapping(() => getFirestore(), mappingWithRelations);
105
+ ```
106
+
107
+ ### 3. Use the repositories
108
+
109
+ ```typescript
110
+ // Create
111
+ const user = await repos.users.create({
112
+ name: "Alice", email: "alice@example.com", age: 28, isActive: true,
113
+ });
114
+ console.log(user.docId); // auto-injected
115
+ console.log(user.documentPath); // auto-injected
116
+
117
+ // Read
118
+ const found = await repos.users.get.byDocId(user.docId);
119
+ const byEmail = await repos.users.get.byEmail("alice@example.com");
120
+
121
+ // Query
122
+ const active = await repos.users.query.byIsActive(true);
123
+
124
+ // Update
125
+ await repos.users.update(user.docId, { age: 29 });
126
+
127
+ // Delete
128
+ await repos.users.delete(user.docId);
129
+
130
+ // Paginate
131
+ const page = await repos.posts.query.paginate({
132
+ pageSize: 10,
133
+ orderBy: [{ field: "createdAt", direction: "desc" }],
134
+ });
135
+ console.log(page.data, page.hasNextPage);
136
+
137
+ // Populate a relation
138
+ const post = await repos.posts.get.byDocId("post_1");
139
+ const withAuthor = await repos.posts.populate(post!, "userId");
140
+ console.log(withAuthor.populated.userId); // UserModel | null
141
+ ```
142
+
143
+ ---
144
+
145
+ # Configuration
146
+
147
+ ## `createRepositoryConfig()`
148
+
149
+ | Parameter | Type | Default | Description |
150
+ |---------------|------------|-------------------|---------------------------------------------------|
151
+ | `path` | `string` | — | Firestore collection path |
152
+ | `isGroup` | `boolean` | — | `true` for collection group queries |
153
+ | `foreignKeys` | `string[]` | — | Keys for `get.by*` (unique retrieval) |
154
+ | `queryKeys` | `string[]` | — | Keys for `query.by*` (list retrieval) |
155
+ | `refCb` | Function | — | Builds the `DocumentReference` |
156
+ | `documentKey` | `string` | `"docId"` | Field auto-injected with the Firestore document ID |
157
+ | `pathKey` | `string` | `"documentPath"` | Field auto-injected with the full Firestore path |
158
+ | `createdKey` | `string` | `"createdAt"` | Field auto-set on creation |
159
+ | `updatedKey` | `string` | `"updatedAt"` | Field auto-updated on every write |
160
+
161
+ ### Simple collection
162
+
163
+ ```typescript
164
+ users: createRepositoryConfig(userSchema)({
165
+ path: "users",
166
+ isGroup: false,
167
+ foreignKeys: ["docId", "email"] as const,
168
+ queryKeys: ["isActive", "name"] as const,
169
+ documentKey: "docId",
170
+ pathKey: "documentPath",
171
+ createdKey: "createdAt",
172
+ updatedKey: "updatedAt",
173
+ refCb: (db: Firestore, docId: string) => db.collection("users").doc(docId),
174
+ });
175
+ ```
176
+
177
+ ### Sub-collection
178
+
179
+ The `refCb` receives parent IDs in order, then the document ID last.
180
+
181
+ ```typescript
182
+ comments: createRepositoryConfig<CommentModel>()({
183
+ path: "comments",
184
+ isGroup: true,
185
+ foreignKeys: ["docId", "postId", "userId"] as const,
186
+ queryKeys: ["postId", "userId"] as const,
187
+ documentKey: "docId",
188
+ pathKey: "documentPath",
189
+ createdKey: "createdAt",
190
+ updatedKey: "updatedAt",
191
+ refCb: (db: Firestore, postId: string, commentId: string) =>
192
+ db.collection("posts").doc(postId).collection("comments").doc(commentId),
193
+ });
194
+ ```
195
+
196
+ ## `buildRepositoryRelations()`
197
+
198
+ Declares relationships between repositories.
199
+
200
+ ```typescript
201
+ const mappingWithRelations = buildRepositoryRelations(repositoryMapping, {
202
+ users: {
203
+ docId: { repo: "posts", key: "userId", type: "many" as const },
204
+ },
205
+ posts: {
206
+ userId: { repo: "users", key: "docId", type: "one" as const },
207
+ docId: { repo: "comments", key: "postId", type: "many" as const },
208
+ },
209
+ comments: {
210
+ postId: { repo: "posts", key: "docId", type: "one" as const },
211
+ userId: { repo: "users", key: "docId", type: "one" as const },
212
+ },
213
+ });
214
+ ```
215
+
216
+ **Validation**
217
+ TypeScript validates that repository names, foreign keys, and relation keys all exist in your mapping.
218
+
219
+ ## `createRepositoryMapping()`
220
+
221
+ ```typescript
222
+ import { getFirestore } from "firebase-admin/firestore";
223
+
224
+ // db is resolved lazily on first access — never at import time.
225
+ export const repos = createRepositoryMapping(() => getFirestore(), mappingWithRelations);
226
+ ```
227
+
228
+ ## Generated methods overview
229
+
230
+ | Namespace | Method | Description |
231
+ |-----------------|------------------------------------------|--------------------------------------------|
232
+ | (root) | `create(data)` | Create with auto ID |
233
+ | (root) | `set(id, data, options?)` | Create / replace with specific ID |
234
+ | (root) | `update(id, data)` | Partial update |
235
+ | (root) | `delete(id)` | Delete document |
236
+ | `get` | `by{ForeignKey}(value)` | Get single document |
237
+ | `get` | `byList(key, values[])` | Get multiple documents by value list |
238
+ | `query` | `by{QueryKey}(value, options?)` | Query by key |
239
+ | `query` | `by(options)` | Generic query (full options) |
240
+ | `query` | `getAll(options?)` | Get all documents |
241
+ | `query` | `paginate(options)` | Cursor-based pagination |
242
+ | `query` | `paginateAll(options)` | Async generator over all pages |
243
+ | `query` | `onSnapshot(options, cb, errCb?)` | Real-time listener |
244
+ | `batch` | `create()` | Create a batch builder (max 500 ops) |
245
+ | `bulk` | `set / update / delete` | Bulk operations (auto-split) |
246
+ | `system` | `backfillKeys(options?)` | Backfill auto-managed system fields |
247
+ | `populate` | `(doc, key \| options)` | Populate related documents |
248
+ | `aggregate` | `count / sum / average` | Server-side aggregations |
249
+ | `transaction` | `run(callback)` | Firestore transaction |
250
+
251
+ ## System fields & `system.backfillKeys()`
252
+
253
+ The optional `documentKey`, `pathKey`, `createdKey` and `updatedKey` config keys
254
+ are **auto-managed**: the package writes them on every `create` / `set` /
255
+ `update` / `batch` / `bulk` operation. Documents written **outside** the package
256
+ (legacy data, manual imports) may lack them — which has consequences:
257
+
258
+ - **Missing `pathKey`** — the CRUD / admin server reconstructs a document's path
259
+ from this field to `update` / `delete` it. Without it, **sub-collection /
260
+ collectionGroup documents can no longer be updated or deleted through the
261
+ server layer** (top-level collections still work).
262
+ - **Missing `createdKey` / `updatedKey`** — any `query.getAll({ orderBy: [[...]] })`
263
+ on that field **silently drops** documents that don't have it (Firestore
264
+ omits documents missing an `orderBy` field).
265
+ - **Missing `documentKey`** — direct reads (`get.by{DocumentKey}`) still work
266
+ (they use the document reference), but the BigQuery sync primary key may be null.
267
+
268
+ `system.backfillKeys()` repairs legacy documents. It streams the whole
269
+ collection (paginated) and fills only the documents that need it:
270
+
271
+ - `pathKey` ← the document's live `ref.path` (the **full** nested path, so
272
+ sub-collection / collectionGroup docs become server-updatable again),
273
+ - `documentKey` ← `doc.id` when missing,
274
+ - `createdKey` ← `now()` **only when missing** (existing timestamps preserved),
275
+ - `updatedKey` ← `now()` **only when missing**.
276
+
277
+ It is idempotent and write-minimal (documents already complete are skipped), so
278
+ it is safe to run repeatedly.
279
+
280
+ ```typescript
281
+ // Migrate legacy documents in place.
282
+ const { scanned, written, skipped, failures } =
283
+ await repos.residences.system.backfillKeys();
284
+
285
+ // Preview without writing.
286
+ const preview = await repos.residences.system.backfillKeys({ dryRun: true });
287
+
288
+ // Observe partial failures instead of throwing.
289
+ await repos.residences.system.backfillKeys({
290
+ pageSize: 500,
291
+ onError: ({ path, error }) => console.error(path, error.message),
292
+ onSuccess: (path) => metrics.inc("backfilled"),
293
+ });
294
+ ```
295
+
296
+ | Option | Default | Description |
297
+ |--------------------|---------|----------------------------------------------------------|
298
+ | `overwriteCreated` | `false` | Rewrite `createdKey` even when already present |
299
+ | `touchUpdated` | `true` | Fill `updatedKey` with now when missing |
300
+ | `overwritePath` | `false` | Always rewrite `pathKey` from the live ref path |
301
+ | `pageSize` | `300` | Documents fetched per page |
302
+ | `dryRun` | `false` | Count what would change without writing |
303
+ | `maxAttempts` | `5` | Retry attempts per document for retryable errors |
304
+ | `onError` | — | Called once per permanently failed document |
305
+ | `onSuccess` | — | Called once per successfully patched document |
306
+
307
+ Returns `{ scanned, written, skipped, failures }`.
308
+
309
+ ---
310
+
311
+ # Relations & Populate
312
+
313
+ ## Defining relations
314
+
315
+ ```typescript
316
+ const mappingWithRelations = buildRepositoryRelations(repositoryMapping, {
317
+ users: {
318
+ docId: { repo: "posts", key: "userId", type: "many" as const },
319
+ },
320
+ posts: {
321
+ userId: { repo: "users", key: "docId", type: "one" as const },
322
+ docId: { repo: "comments", key: "postId", type: "many" as const },
323
+ },
324
+ comments: {
325
+ postId: { repo: "posts", key: "docId", type: "one" as const },
326
+ userId: { repo: "users", key: "docId", type: "one" as const },
327
+ },
328
+ });
329
+ ```
330
+
331
+ Each entry maps a field in the source model to a target repository:
332
+
333
+ | Field | Description |
334
+ |----------------|------------------------------------------------|
335
+ | `repo` | Name of the target repository |
336
+ | `key` | Field on the target repo used for the lookup |
337
+ | `type: "one"` | The field holds a single ID → returns one doc |
338
+ | `type: "many"` | The field holds an ID used to filter → returns array |
339
+
340
+ ## `populate()` — on a single document
341
+
342
+ ```typescript
343
+ const post = await repos.posts.get.byDocId("post_1");
344
+
345
+ // One relation key
346
+ const withAuthor = await repos.posts.populate(post!, "userId");
347
+ console.log(withAuthor.populated.userId); // UserModel | null
348
+
349
+ // One relation with field projection
350
+ const withAuthorPartial = await repos.posts.populate(post!, {
351
+ relation: "userId",
352
+ select: ["docId", "name", "email"], // typed to UserModel keys
353
+ });
354
+
355
+ // Multiple relations
356
+ const full = await repos.posts.populate(post!, ["userId", "docId"]);
357
+ console.log(full.populated.userId); // UserModel | null
358
+ console.log(full.populated.docId); // CommentModel[]
359
+ ```
360
+
361
+ **Naming**
362
+ The populated result is keyed by the **source field name** (not the target repo name).
363
+ `post.populated.userId` → the user, `post.populated.docId` → the comments.
364
+
365
+ ## `include` — populate during pagination
366
+
367
+ Use `include` in `paginate` or `paginateAll` to populate all relations for every document of the page:
368
+
369
+ ```typescript
370
+ const page = await repos.posts.query.paginate({
371
+ pageSize: 10,
372
+ include: [
373
+ "docId", // comments (many)
374
+ { relation: "userId", select: ["docId", "name"] }, // author (one), partial
375
+ ],
376
+ });
377
+
378
+ for (const post of page.data) {
379
+ console.log(post.populated.docId); // CommentModel[]
380
+ console.log(post.populated.userId); // { docId: string; name: string }
381
+ }
382
+ ```
383
+
384
+ Works the same with `paginateAll`:
385
+
386
+ ```typescript
387
+ for await (const page of repos.posts.query.paginateAll({
388
+ pageSize: 100,
389
+ include: ["userId"],
390
+ })) {
391
+ // page.data[n].populated.userId is populated
392
+ }
393
+ ```
394
+
395
+ ## Exported types
396
+
397
+ ```typescript
398
+ import type {
399
+ PopulateOptionsTyped, // typed populate options with keyof select
400
+ IncludeConfigTyped, // typed include config for pagination
401
+ PaginationWithIncludeOptionsTyped, // full pagination + include options
402
+ } from "@lpdjs/firestore-repo-service";
403
+ ```
404
+
405
+ ---
406
+
407
+ # Querying
408
+
409
+ ## GET Methods
410
+
411
+ Retrieve a **single document** by a foreign key.
412
+
413
+ ```typescript
414
+ const user = await repos.users.get.byDocId("user123");
415
+ const user2 = await repos.users.get.byEmail("alice@example.com");
416
+
417
+ // With raw DocumentSnapshot
418
+ const result = await repos.users.get.byDocId("user123", true);
419
+ if (result) {
420
+ console.log(result.data); // UserModel
421
+ console.log(result.doc); // DocumentSnapshot
422
+ }
423
+
424
+ // Batch get
425
+ const users = await repos.users.get.byList("docId", ["u1", "u2", "u3"]);
426
+ ```
427
+
428
+ ## QUERY Methods
429
+
430
+ Search for **multiple documents** by a query key.
431
+
432
+ ```typescript
433
+ const activeUsers = await repos.users.query.byIsActive(true);
434
+ const byName = await repos.users.query.byName("Alice");
435
+
436
+ // With extra options
437
+ const results = await repos.users.query.byIsActive(true, {
438
+ where: [["age", ">=", 18]],
439
+ orderBy: [{ field: "name", direction: "asc" }],
440
+ limit: 50,
441
+ });
442
+
443
+ // Generic query
444
+ const users = await repos.users.query.by({
445
+ where: [
446
+ ["isActive", "==", true],
447
+ ["age", ">=", 18],
448
+ ],
449
+ orderBy: [{ field: "createdAt", direction: "desc" }],
450
+ limit: 10,
451
+ select: ["docId", "name", "email"],
452
+ });
453
+
454
+ // Get all
455
+ const all = await repos.users.query.getAll();
456
+ ```
457
+
458
+ ## OR Conditions
459
+
460
+ ### `orWhere` — simple OR
461
+
462
+ Each clause is independently OR'd. Base `where` conditions are applied to **every** branch.
463
+
464
+ ```typescript
465
+ // status == "draft" OR status == "published"
466
+ const posts = await repos.posts.query.by({
467
+ orWhere: [
468
+ ["status", "==", "draft"],
469
+ ["status", "==", "published"],
470
+ ],
471
+ });
472
+
473
+ // (isActive == true) AND (userId == "A" OR userId == "B")
474
+ const posts2 = await repos.posts.query.by({
475
+ where: [["isActive", "==", true]], // applied to every branch
476
+ orWhere: [
477
+ ["userId", "==", "user-A"],
478
+ ["userId", "==", "user-B"],
479
+ ],
480
+ });
481
+ ```
482
+
483
+ ### `orWhereGroups` — compound OR (AND-within-OR)
484
+
485
+ ```typescript
486
+ // (status=="published" AND views>100) OR (status=="draft" AND userId=="me")
487
+ const posts = await repos.posts.query.by({
488
+ orWhereGroups: [
489
+ [["status", "==", "published"], ["views", ">", 100]],
490
+ [["status", "==", "draft"], ["userId", "==", myId]],
491
+ ],
492
+ });
493
+ ```
494
+
495
+ **Under the hood**
496
+ OR conditions are simulated by running one Firestore query per branch in parallel, then merging results in memory (dedup by document ID). No 30-disjunction limit applies.
497
+
498
+ `in` / `array-contains-any` operators with >30 values are automatically split into chunks of 30 queries.
499
+
500
+ ## QueryOptions reference
501
+
502
+ ```typescript
503
+ interface QueryOptions<T> {
504
+ where?: [keyof T, WhereFilterOp, any][]; // AND conditions
505
+ orWhere?: [keyof T, WhereFilterOp, any][]; // simple OR (one clause per entry)
506
+ orWhereGroups?: [keyof T, WhereFilterOp, any][][]; // compound OR (AND groups)
507
+ orderBy?: { field: keyof T; direction?: "asc" | "desc" }[];
508
+ limit?: number;
509
+ offset?: number;
510
+ select?: (keyof T)[]; // field projection
511
+ startAt?: DocumentSnapshot | any[];
512
+ startAfter?: DocumentSnapshot | any[];
513
+ endAt?: DocumentSnapshot | any[];
514
+ endBefore?: DocumentSnapshot | any[];
515
+ }
516
+ ```
517
+
518
+ ## Pagination
519
+
520
+ Cursor-based pagination — efficient for large collections.
521
+
522
+ ```typescript
523
+ // First page
524
+ const page1 = await repos.posts.query.paginate({
525
+ pageSize: 10,
526
+ orderBy: [{ field: "createdAt", direction: "desc" }],
527
+ });
528
+
529
+ // page1.data → PostModel[]
530
+ // page1.hasNextPage → boolean
531
+ // page1.hasPrevPage → boolean
532
+ // page1.nextCursor → DocumentSnapshot | undefined
533
+ // page1.prevCursor → DocumentSnapshot | undefined
534
+
535
+ // Next page
536
+ const page2 = await repos.posts.query.paginate({
537
+ pageSize: 10,
538
+ cursor: page1.nextCursor,
539
+ direction: "next", // default
540
+ });
541
+
542
+ // Previous page
543
+ const prev = await repos.posts.query.paginate({
544
+ pageSize: 10,
545
+ cursor: page2.prevCursor,
546
+ direction: "prev",
547
+ });
548
+ ```
549
+
550
+ ### Paginate with filters and OR
551
+
552
+ ```typescript
553
+ const page = await repos.posts.query.paginate({
554
+ pageSize: 10,
555
+ where: [["status", "==", "published"]],
556
+ orWhere: [
557
+ ["userId", "==", currentUserId],
558
+ ["featured", "==", true],
559
+ ],
560
+ orderBy: [{ field: "createdAt", direction: "desc" }],
561
+ });
562
+ ```
563
+
564
+ ### Paginate with `include` (populate relations per page)
565
+
566
+ ```typescript
567
+ const page = await repos.posts.query.paginate({
568
+ pageSize: 10,
569
+ include: [
570
+ "docId", // many → CommentModel[]
571
+ { relation: "userId", select: ["docId", "name"] }, // one → partial UserModel
572
+ ],
573
+ });
574
+
575
+ for (const post of page.data) {
576
+ console.log(post.populated.docId); // CommentModel[]
577
+ console.log(post.populated.userId); // { docId, name }
578
+ }
579
+ ```
580
+
581
+ ## Iterate all pages — `paginateAll`
582
+
583
+ Async generator that automatically advances cursors. Ideal for migrations and exports.
584
+
585
+ ```typescript
586
+ for await (const page of repos.posts.query.paginateAll({ pageSize: 100 })) {
587
+ console.log(`${page.data.length} posts on this page`);
588
+ }
589
+
590
+ // With include
591
+ for await (const page of repos.posts.query.paginateAll({
592
+ pageSize: 100,
593
+ include: [{ relation: "userId", select: ["name"] }],
594
+ })) {
595
+ for (const post of page.data) {
596
+ console.log(post.populated.userId?.name);
597
+ }
598
+ }
599
+ ```
600
+
601
+ ## Real-time listener
602
+
603
+ ```typescript
604
+ const unsub = repos.users.query.onSnapshot(
605
+ { where: [["isActive", "==", true]] },
606
+ (users) => console.log(users),
607
+ (err) => console.error(err),
608
+ );
609
+
610
+ unsub(); // stop listening
611
+ ```
612
+
613
+ ---
614
+
615
+ # Advanced Usage
616
+
617
+ ## CRUD `set()`
618
+
619
+ Creates or replaces a document with a specific ID.
620
+ `documentKey` and `pathKey` are automatically injected (same as `create()`).
621
+
622
+ ```typescript
623
+ const post = await repos.posts.set("my-post-id", {
624
+ title: "Hello",
625
+ status: "draft",
626
+ userId: "user_1",
627
+ // docId / documentPath are injected automatically
628
+ });
629
+ console.log(post.docId); // "my-post-id"
630
+ console.log(post.documentPath); // "posts/my-post-id"
631
+
632
+ // With merge option
633
+ await repos.posts.set("my-post-id", { title: "Updated" }, { merge: true });
634
+ ```
635
+
636
+ ## Batch Operations
637
+
638
+ Atomic write up to 500 operations.
639
+
640
+ ```typescript
641
+ const batch = repos.posts.batch.create();
642
+
643
+ batch.set("post-1", { title: "Post 1", userId: "u1", status: "draft" });
644
+ batch.set("post-2", { title: "Post 2", userId: "u1", status: "published" });
645
+ batch.update("post-3", { status: "published" });
646
+ batch.delete("post-old");
647
+
648
+ await batch.commit();
649
+ ```
650
+
651
+ ### Sub-collection batch
652
+
653
+ Pass parent IDs before the document ID:
654
+
655
+ ```typescript
656
+ const batch = repos.comments.batch.create();
657
+
658
+ batch.set(postId, "comment-1", { postId, userId, content: "Hello!", likes: 0 });
659
+ batch.set(postId, "comment-2", { postId, userId, content: "World!", likes: 0 });
660
+
661
+ await batch.commit();
662
+ ```
663
+
664
+ ## Bulk Operations
665
+
666
+ Auto-split into batches of 500 for large datasets.
667
+
668
+ ```typescript
669
+ const db = getFirestore();
670
+
671
+ // Bulk set
672
+ await repos.users.bulk.set([
673
+ { docRef: db.collection("users").doc("u1"), data: { name: "Alice" }, merge: true },
674
+ { docRef: db.collection("users").doc("u2"), data: { name: "Bob" } },
675
+ // ... thousands of documents
676
+ ]);
677
+
678
+ // Bulk update
679
+ await repos.users.bulk.update([
680
+ { docRef: db.collection("users").doc("u1"), data: { age: 30 } },
681
+ ]);
682
+
683
+ // Bulk delete
684
+ await repos.users.bulk.delete([
685
+ db.collection("users").doc("u1"),
686
+ db.collection("users").doc("u2"),
687
+ ]);
688
+ ```
689
+
690
+ ## Aggregations
691
+
692
+ Server-side — no documents transferred.
693
+
694
+ ```typescript
695
+ const total = await repos.users.aggregate.count();
696
+ const active = await repos.users.aggregate.count({ where: [["isActive", "==", true]] });
697
+ const ageSum = await repos.users.aggregate.sum("age");
698
+ const ageAvg = await repos.users.aggregate.average("age", {
699
+ where: [["isActive", "==", true]],
700
+ });
701
+ ```
702
+
703
+ ## Transactions
704
+
705
+ ```typescript
706
+ await repos.users.transaction.run(async (tx) => {
707
+ const user = await tx.get("user_1");
708
+ if (!user) throw new Error("not found");
709
+ await tx.update("user_1", { age: user.age + 1 });
710
+ });
711
+ ```
712
+
713
+ ## Real-time listener
714
+
715
+ ```typescript
716
+ const unsub = repos.users.query.onSnapshot(
717
+ { where: [["isActive", "==", true]], orderBy: [{ field: "name" }] },
718
+ (users) => console.log("live:", users),
719
+ (err) => console.error(err),
720
+ );
721
+
722
+ // Later:
723
+ unsub();
724
+ ```
725
+
726
+ ## OR queries — advanced patterns
727
+
728
+ ```typescript
729
+ // Simple OR (one clause per entry)
730
+ await repos.posts.query.by({
731
+ where: [["isPublic", "==", true]], // applied to every OR branch
732
+ orWhere: [
733
+ ["userId", "==", "user-A"],
734
+ ["authorId", "==", "user-A"],
735
+ ],
736
+ });
737
+
738
+ // Compound OR: (A AND B) OR (C AND D)
739
+ await repos.posts.query.by({
740
+ orWhereGroups: [
741
+ [["status", "==", "published"], ["views", ">", 1000]],
742
+ [["status", "==", "featured"], ["pinned", "==", true]],
743
+ ],
744
+ });
745
+ ```
746
+
747
+ ## `in` operator with >30 values
748
+
749
+ Automatically split into multiple queries:
750
+
751
+ ```typescript
752
+ const ids = Array.from({ length: 90 }, (_, i) => `id-${i}`);
753
+
754
+ // Generates 3 Firestore queries (30+30+30) merged in memory
755
+ const docs = await repos.users.query.by({
756
+ where: [["docId", "in", ids]],
757
+ });
758
+ ```
759
+
760
+ ## Date handling (`setDateHandling`)
761
+
762
+ Firestore stores dates as `Timestamp` objects. By default the SDK returns them
763
+ as raw `Timestamp` instances on reads — which is great if you stay in JS land
764
+ but a pain for JSON APIs, OpenAPI, BigQuery downstream consumers, or any code
765
+ that expects native `Date` / ISO strings.
766
+
767
+ `setDateHandling()` is a global switch with two modes:
768
+
769
+ ```typescript
770
+ import { setDateHandling } from "@lpdjs/firestore-repo-service";
771
+
772
+ // At the top of your bootstrap (server start, function init, etc.)
773
+ setDateHandling("normalize"); // or "preserve"
774
+ ```
775
+
776
+ ### `"preserve"` (default — non-breaking)
777
+
778
+ Behavior unchanged:
779
+
780
+ - Repo reads return raw `Timestamp` objects.
781
+ - CRUD `z.date()` validation is strict (rejects ISO strings).
782
+ - CRUD JSON output may contain `{ _seconds, _nanoseconds }` if you pass a
783
+ `Timestamp` straight through.
784
+
785
+ Pick this if you already deal with `Timestamp` in your code and don't want any
786
+ behavior change.
787
+
788
+ ### `"normalize"` (recommended for new projects)
789
+
790
+ Everything converges on **JS `Date`** in code and **ISO 8601 strings** over the wire:
791
+
792
+ | Layer | Behavior |
793
+ |-----------------------------------------|--------------------------------------------------------------------------|
794
+ | `get.by*`, `getAll`, `query.by*` | Recursively converts `Timestamp` → `Date` (incl. nested objects/arrays). |
795
+ | `paginate`, `transaction.get` | Same recursive normalization. |
796
+ | `create`, `set`, `update` return values | Same recursive normalization. |
797
+ | CRUD input validation | `z.date()` is wrapped in `z.preprocess(coerceToDate)` and accepts: `Date`, `Timestamp`, ISO string, `{_seconds,_nanoseconds}`, epoch ms. |
798
+ | CRUD JSON output | `Date` → ISO string (native), no more `{_seconds,_nanoseconds}` leakage. |
799
+ | OpenAPI | `z.date()` documented as `string` / `format: date-time` (matches runtime). |
800
+ | BigQuery sync | Unchanged — works identically with `Date` or `Timestamp`. |
801
+ | Admin server | Unchanged — already defensive (handles all formats). |
802
+
803
+ ### Helpers
804
+
805
+ The conversion utilities are exported in case you need them manually:
806
+
807
+ ```typescript
808
+ import {
809
+ coerceToDate,
810
+ normalizeTimestamps,
811
+ getDateHandling,
812
+ } from "@lpdjs/firestore-repo-service";
813
+
814
+ // Date | Timestamp | ISO | epoch ms | {_seconds,_nanoseconds} -> Date | null
815
+ const d = coerceToDate(req.body.publishedAt);
816
+
817
+ // Recursively converts Timestamps to Dates inside any value
818
+ const normalized = normalizeTimestamps(somePayload);
819
+
820
+ // Read the current global mode
821
+ getDateHandling(); // "preserve" | "normalize"
822
+ ```
823
+
824
+ ---
825
+
826
+ # Admin Server
827
+
828
+ The admin UI is built via `createServers(repos).admin(...)` — a unified factory that auto-binds the repository registry so each entry's `repo` field is inferred from its key (and so are model field paths in `fieldsConfig`).
829
+
830
+ **Features:**
831
+ - Dashboard listing all repositories
832
+ - Document list with cursor-based pagination, sortable columns, rows-per-page selector
833
+ - Filter bar generated from `fieldsConfig` (fields with `"filterable"` role)
834
+ - Create / Edit forms generated from Zod schemas
835
+ - Relational action columns (navigate to related repo)
836
+ - HTTP Basic Auth or custom middleware guard
837
+ - Zero JavaScript framework — DaisyUI + plain HTML
838
+
839
+ ## Basic setup
840
+
841
+ ```typescript
842
+ import { onRequest } from "firebase-functions/https";
843
+ import { createServers } from "@lpdjs/firestore-repo-service";
844
+
845
+ const servers = createServers(repos, {
846
+ onRequest,
847
+ httpsOptions: { invoker: "public" },
848
+ });
849
+
850
+ export const admin = servers.admin({
851
+ basePath: "/admin",
852
+ auth: {
853
+ type: "basic",
854
+ realm: "Admin",
855
+ username: "admin",
856
+ password: process.env.ADMIN_PASSWORD!,
857
+ },
858
+ repos: {
859
+ users: {
860
+ path: "users",
861
+ fieldsConfig: {
862
+ name: ["create", "mutable", "filterable"],
863
+ email: ["create", "mutable", "filterable"],
864
+ age: ["create", "mutable", "filterable"],
865
+ isActive: ["create", "mutable", "filterable"],
866
+ docId: ["filterable"],
867
+ },
868
+ allowDelete: true,
869
+ },
870
+ posts: {
871
+ path: "posts",
872
+ fieldsConfig: {
873
+ title: ["create", "mutable"],
874
+ content: ["create", "mutable"],
875
+ status: ["create", "mutable", "filterable"],
876
+ userId: ["create", "filterable"],
877
+ },
878
+ relationalFields: [
879
+ { key: "userId", column: "Author" }, // button → /users?fv_docId=<value>
880
+ ],
881
+ allowDelete: false,
882
+ },
883
+ },
884
+ });
885
+ ```
886
+
887
+ When `onRequest` is passed to `createServers`, `servers.admin()` returns a ready-to-export Cloud Function. Without it, it returns a raw HTTP handler that you can wrap yourself (its `.httpsOptions` are forwarded for convenience).
888
+
889
+ ## AdminRepoConfig options
890
+
891
+ | Field | Type | Default | Description |
892
+ |----------------------|----------------------------------|-----------|-----------------------------------------------------|
893
+ | `path` | `string` | required | Display path in the UI |
894
+ | `schema` | `ZodObject` | auto | Zod schema (auto-detected when using `createRepositoryConfig(schema)`) |
895
+ | `documentKey` | `string` | `"docId"` | Field used as document ID |
896
+ | `listColumns` | `string[]` | all keys | Columns shown in the list view |
897
+ | `pageSize` | `number` | `25` | Default rows per page |
898
+ | `fieldsConfig` | `Record<FieldPath, FieldRole[]>` | all keys | Per-field role config: `"create"`, `"mutable"`, `"filterable"` |
899
+ | `allowDelete` | `boolean` | `false` | Show Delete button in the list |
900
+ | `relationalFields` | `{ key, column }[]` | none | Relational action button columns |
901
+
902
+ > The `repo` field is **not** part of `AdminRepoConfig` anymore — it is automatically injected from the registry key (e.g. `posts:` → `repos.posts`).
903
+
904
+ ## fieldsConfig with dot-notation
905
+
906
+ Fields support dot-notation for nested Zod objects:
907
+
908
+ ```typescript
909
+ fieldsConfig: {
910
+ status: ["filterable"],
911
+ "address.city": ["create", "mutable", "filterable"],
912
+ "address.street": ["create", "mutable", "filterable"],
913
+ title: ["create", "mutable"],
914
+ }
915
+ ```
916
+
917
+ The filter bar builds the correct Firestore path (`address.city`) automatically.
918
+
919
+ ## Relational fields
920
+
921
+ Each entry adds a dedicated button column in the list view.
922
+ The button navigates to the linked repository filtered by the field value.
923
+
924
+ ```typescript
925
+ // On the posts repo: "Author" button goes to /users?fv_docId=<post.userId>
926
+ relationalFields: [{ key: "userId", column: "Author" }]
927
+
928
+ // On the users repo: "Posts" button goes to /posts?fv_userId=<user.docId>
929
+ relationalFields: [{ key: "docId", column: "Posts" }]
930
+ ```
931
+
932
+ Relations are resolved automatically from `buildRepositoryRelations` — no extra config needed.
933
+
934
+ ## Pagination controls
935
+
936
+ The list view supports:
937
+ - **Cursor navigation**: ← Previous / Next → buttons (cursor-based, correct prev/next detection)
938
+ - **Rows per page**: [10] [25] [50] [100] selector (querystring `?ps=N`)
939
+ - **Column sort**: click any column header to sort asc → desc → default (querystring `?ob=field&od=asc|desc`)
940
+ - **Filters**: persist across pagination and sort changes
941
+
942
+ ## Authentication
943
+
944
+ ### HTTP Basic Auth
945
+
946
+ ```typescript
947
+ auth: {
948
+ type: "basic",
949
+ realm: "Admin Area",
950
+ username: "admin",
951
+ password: "secret",
952
+ }
953
+ ```
954
+
955
+ ### Custom middleware
956
+
957
+ ```typescript
958
+ auth: async (req, res, next) => {
959
+ const token = req.headers["x-api-key"];
960
+ if (token !== process.env.API_KEY) {
961
+ res.status(401).send("Unauthorized");
962
+ return;
963
+ }
964
+ next();
965
+ }
966
+ ```
967
+
968
+ ### Additional middleware
969
+
970
+ ```typescript
971
+ createServers(repos).admin({
972
+ middleware: [
973
+ (req, res, next) => {
974
+ console.log(req.method, req.url);
975
+ next();
976
+ },
977
+ ],
978
+ // ...
979
+ })
980
+ ```
981
+
982
+ ## Firebase HttpsOptions
983
+
984
+ Pass any `HttpsOptions` (invoker, region, memory, etc.) at the `createServers` level (applied to every server) or per-server. When `onRequest` is provided to `createServers`, the returned value is already a ready-to-deploy Cloud Function:
985
+
986
+ ```typescript
987
+ const servers = createServers(repos, {
988
+ onRequest,
989
+ httpsOptions: { invoker: "public", memory: "512MiB" },
990
+ });
991
+
992
+ export const admin = servers.admin({ /* ... */ });
993
+ ```
994
+
995
+ If you don't pass `onRequest`, you get the raw handler back (its `.httpsOptions` are still attached for convenience):
996
+
997
+ ```typescript
998
+ const handler = createServers(repos).admin({
999
+ httpsOptions: { invoker: "public", memory: "512MiB" },
1000
+ // ...
1001
+ });
1002
+
1003
+ export const admin = onRequest(handler.httpsOptions!, handler);
1004
+ ```
1005
+
1006
+ Available options include `invoker`, `region`, `memory`, `timeoutSeconds`, `minInstances`,
1007
+ `maxInstances`, `concurrency`, `cors`, `serviceAccount`, `secrets`, etc.
1008
+
1009
+ ## Composite Index Error Handling
1010
+
1011
+ When a query requires a composite index that doesn't exist, Firestore throws `FAILED_PRECONDITION` (code 9).
1012
+ The admin server catches this error and displays a helpful alert with a direct link to create the index:
1013
+
1014
+ - **Regular collections**: the error message often contains the Firebase Console URL — the admin extracts it automatically
1015
+ - **Collection groups**: Firestore does *not* include the URL — the admin **generates** it from the query context (filters, sort, collection ID, project ID)
1016
+
1017
+ The list view shows a **warning alert** with a "Create Index →" button linking directly to the Firebase Console index creation wizard.
1018
+
1019
+ ### Filter bar index hint
1020
+
1021
+ When two or more filters are active (or any filter on a collection group), the filter bar displays a subtle info badge:
1022
+
1023
+ > ⚠ This query may require a composite index.
1024
+
1025
+ This proactive hint helps before the query even fails.
1026
+
1027
+ ### `QueryError` type
1028
+
1029
+ ```typescript
1030
+ interface QueryError {
1031
+ type: "index" | "error";
1032
+ message: string;
1033
+ indexUrl?: string; // Firebase Console URL (always present for "index" type)
1034
+ }
1035
+ ```
1036
+
1037
+ ## CRUD API Server
1038
+
1039
+ For client-facing REST endpoints with validation, pagination, and relation population, use `createServers(repos).crud(...)`:
1040
+
1041
+ ```typescript
1042
+ const servers = createServers(repos, { onRequest });
1043
+
1044
+ export const api = servers.crud({
1045
+ basePath: "/api",
1046
+ repos: {
1047
+ posts: {
1048
+ schema: postSchema,
1049
+ path: "posts",
1050
+ fieldsConfig: {
1051
+ status: ["filterable"],
1052
+ authorId: ["filterable"],
1053
+ },
1054
+ allowDelete: true,
1055
+ },
1056
+ },
1057
+ });
1058
+ ```
1059
+
1060
+ ### Firebase Auth (cookie session for admin, bearer for CRUD)
1061
+
1062
+ A built-in helper wires Firebase Authentication into both `servers.admin()` and `servers.crud()`:
1063
+
1064
+ ```typescript
1065
+ import { firebaseAuth } from "@lpdjs/firestore-repo-service/servers/auth";
1066
+ import { getAuth } from "firebase-admin/auth";
1067
+
1068
+ // Admin: cookie session + auto-mounted /__login page
1069
+ auth: firebaseAuth({
1070
+ getAuth,
1071
+ mode: "cookie", // default for admin
1072
+ allow: (u) => {
1073
+ const role = u.claims.role as string | undefined;
1074
+ if (role === "superAdmin" || role === "admin" || role === "viewer") {
1075
+ return { role }; // becomes req.user.context
1076
+ }
1077
+ return null; // → 302 to /__login
1078
+ },
1079
+ })
1080
+ ```
1081
+
1082
+ Modes:
1083
+
1084
+ - `"cookie"` — auto-mounts `GET /__login`, `POST /__session`, `POST /__logout`. Uses HttpOnly cookies. Best for browser admin UIs.
1085
+ - `"bearer"` — verifies `Authorization: Bearer <idToken>`. Best for REST/CRUD APIs.
1086
+ - `"both"` — accepts either; useful for hybrid backends.
1087
+
1088
+ The `allow()` callback maps a verified Firebase user to your business context (returning `null` rejects). Whatever it returns becomes `req.user.context` inside handlers and rules.
1089
+
1090
+ **Auth emulator**
1091
+ The Admin SDK already targets the Auth emulator when `FIREBASE_AUTH_EMULATOR_HOST` is set. The bundled login page now follows suit: pass `authEmulatorHost` (defaults to that same env var) and its client SDK is wired with `connectAuthEmulator`, so local `firebase emulators:start` sign-ins work end-to-end. Pass `authEmulatorHost: ""` to force production even under the emulator. This applies to `servers.admin()`, `servers.crud()` and the sync admin alike.
1092
+
1093
+ **Non-`us-central1` region?** Under the emulator the region isn't reliably exposed, so the login page's same-function URLs fall back to `us-central1` — making the session POST 404 when you deploy elsewhere. Pass your region to `firebaseAuth({ region: "europe-west1", ... })` so the login/session prefix is correct. (The rest of the admin UI links pick the region up automatically from `httpsOptions.region`.)
1094
+
1095
+ ## Per-repo authorization rules (CRUD)
1096
+
1097
+ When `auth` is set on `servers.crud()`, each repo follows a **default-deny** policy: any operation without an explicit `rules.<op>` returns `403`. Use `allowAll` or `() => true` to explicitly open one.
1098
+
1099
+ ```typescript
1100
+ import { firebaseAuth, allowAll } from "@lpdjs/firestore-repo-service/servers/auth";
1101
+
1102
+ export const api = servers.crud({
1103
+ auth: firebaseAuth({ getAuth, mode: "bearer" }),
1104
+ repos: {
1105
+ comments: {
1106
+ path: "comments",
1107
+ allowDelete: true,
1108
+ rules: {
1109
+ list: allowAll,
1110
+ get: allowAll,
1111
+ create: ({ user }) => !!user.uid,
1112
+ update: ({ user, doc }) => doc.authorId === user.uid,
1113
+ delete: ({ user, doc }) =>
1114
+ doc.authorId === user.uid || user.context?.role === "moderator",
1115
+ // Row-level filter applied to every doc returned by list/query/get
1116
+ filter: ({ user, doc }) =>
1117
+ doc.public || doc.authorId === user.uid,
1118
+ },
1119
+ },
1120
+ },
1121
+ });
1122
+ ```
1123
+
1124
+ Each rule receives a typed context (`user`, plus `doc` / `body` / `query` / `params` depending on the op) and returns `boolean | Promise<boolean>`. Rules are intentionally **per-repo** so each collection can use its own business roles, independent from any admin RBAC trio.
1125
+
1126
+ ---
1127
+
1128
+ # Change History
1129
+
1130
+ Opt-in, per-repo change-history capture using Firestore triggers, with a typed read API on the configured repository.
1131
+
1132
+ - **Reliable** – triggers fire for every write (back-office, scripts, admin console, other services).
1133
+ - **Typed** – meta fields are declared inside the repo config and validated against the model.
1134
+ - **Backward compatible** – the read API normalises both the new v2 schema (1 doc per update) and a legacy v1 schema (1 doc per modified field) into a single shape.
1135
+
1136
+ ## Architecture
1137
+
1138
+ ```
1139
+ Firestore write ──► onDocumentWritten trigger ──► history subcollection
1140
+ (diff + meta) ({path}/{id}/history/{historyId})
1141
+
1142
+ repo.history.list(...) ◄── normalises v1 + v2 ──── unified HistoryEntry<T>
1143
+ ```
1144
+
1145
+ History documents are stored in a subcollection of the entity (default `history`). Each repo opts in independently.
1146
+
1147
+ ## Quick Start
1148
+
1149
+ ### 1. Enable history on a repo
1150
+
1151
+ ```typescript
1152
+ import { createRepositoryConfig } from "@lpdjs/firestore-repo-service";
1153
+ import { residenceSchema } from "./schemas";
1154
+
1155
+ export const residenceRepoConfig = createRepositoryConfig(residenceSchema)({
1156
+ path: "residences",
1157
+ documentKey: "id",
1158
+ // ... other config ...
1159
+ history: {
1160
+ enabled: true,
1161
+ // subcollection: "history", // default
1162
+ meta: {
1163
+ // each entry maps a meta key to a model field — must exist on the model
1164
+ userId: "updatedBy",
1165
+ userEmail: "updatedByEmail",
1166
+ reason: "changeReason",
1167
+ comment: "changeComment",
1168
+ // free-form extras: copied as-is to meta.extras
1169
+ extras: ["source"],
1170
+ },
1171
+ // diff scope
1172
+ // include: ["name", "address"], // if set, only these fields are diffed
1173
+ exclude: ["lastSeenAt"], // never tracked
1174
+ // ttl: { field: "expiresAt", days: 365 },
1175
+ },
1176
+ });
1177
+ ```
1178
+
1179
+ > Meta fields are auto-excluded from the diff (so `updatedBy` flipping won't appear as its own change). The repo type uses `keyof Model`, so a typo on a meta field fails at compile time.
1180
+
1181
+ ### 2. Wire the triggers (Cloud Functions entry point)
1182
+
1183
+ Two equivalent ways:
1184
+
1185
+ #### Via `createServers` (recommended)
1186
+
1187
+ `servers.history()` lives next to `servers.api()` / `servers.sync()` and reuses the same `repos` instance. Note that history is built on Firestore triggers, so it does **not** inherit `httpsOptions` from the API server.
1188
+
1189
+ ```typescript
1190
+ import { createServers } from "@lpdjs/firestore-repo-service";
1191
+ import * as firestoreTriggers from "firebase-functions/v2/firestore";
1192
+ import { repos } from "./repos";
1193
+
1194
+ const servers = createServers({ repos /* …other deps */ });
1195
+
1196
+ export const historyTriggers = servers.history({
1197
+ deps: { onDocumentWritten: firestoreTriggers.onDocumentWritten },
1198
+ defaults: { ttl: { field: "expiresAt", days: 365 } },
1199
+ repos: {
1200
+ residences: { exclude: ["internalState"] },
1201
+ },
1202
+ });
1203
+
1204
+ // One trigger per history-enabled repo, named `{repoName}_onHistory`.
1205
+ export const { residences_onHistory } = historyTriggers;
1206
+ ```
1207
+
1208
+ #### Via the standalone factory
1209
+
1210
+ ```typescript
1211
+ import { createHistoryTriggers } from "@lpdjs/firestore-repo-service/history";
1212
+ import * as firestoreTriggers from "firebase-functions/v2/firestore";
1213
+ import { repos } from "./repos";
1214
+
1215
+ const triggers = createHistoryTriggers(repos, {
1216
+ deps: { onDocumentWritten: firestoreTriggers.onDocumentWritten },
1217
+ defaults: { ttl: { field: "expiresAt", days: 365 } },
1218
+ });
1219
+
1220
+ export const { residences_onHistory } = triggers;
1221
+ ```
1222
+
1223
+ For `collectionGroup` repos, set `triggerPath` in the per-repo override (same constraint as sync triggers).
1224
+
1225
+ ### 3. Read history from your code
1226
+
1227
+ ```typescript
1228
+ const entries = await repo.residences.history.list("residence_123", {
1229
+ limit: 50,
1230
+ direction: "desc",
1231
+ fields: ["name", "address"], // optional filter
1232
+ operations: ["update"], // optional filter
1233
+ });
1234
+
1235
+ for (const entry of entries) {
1236
+ console.log(entry.historySetAt.toDate(), entry.meta.userId, entry.operation);
1237
+ for (const [field, change] of Object.entries(entry.changes)) {
1238
+ console.log(` ${field}: ${change.oldValue} → ${change.newValue}`);
1239
+ }
1240
+ }
1241
+ ```
1242
+
1243
+ For deeper subcollections, pass parent path segments before the docId, just like the rest of the repo API.
1244
+
1245
+ ## Admin UI integration
1246
+
1247
+ When a repo has `history.enabled: true`, the admin server **automatically**:
1248
+
1249
+ - Detects the history namespace on the repo (`repo.history` presence).
1250
+ - Shows a **History** button on every row of the list view.
1251
+ - Exposes a dedicated route `GET /:repoName/:id/history` that renders the timeline as a table (timestamp, operation badge, user, reason/comment, per-field `oldValue → newValue`).
1252
+
1253
+ No extra wiring required — the moment you enable history on a repo, it shows up in the admin.
1254
+
1255
+ ## Stored schema (v2)
1256
+
1257
+ ```jsonc
1258
+ // {collectionPath}/{docId}/history/{historyId}
1259
+ {
1260
+ "schemaVersion": 2,
1261
+ "historyDocId": "uuid",
1262
+ "historyToObjectId": "residence_123",
1263
+ "historySetAt": Timestamp,
1264
+ "operation": "create" | "update" | "delete",
1265
+ "meta": {
1266
+ "userId": "user_42",
1267
+ "userEmail": "...",
1268
+ "reason": "...",
1269
+ "comment": "...",
1270
+ "extras": { "source": "back-office" }
1271
+ },
1272
+ "changes": {
1273
+ "name": { "oldValue": "A", "newValue": "B", "type": { "old": "string", "new": "string" } },
1274
+ "address": { "oldValue": {…}, "newValue": {…}, "type": { "old": "object", "new": "object" } }
1275
+ },
1276
+ "expiresAt": Timestamp // only when ttl is configured
1277
+ }
1278
+ ```
1279
+
1280
+ ## Read API
1281
+
1282
+ | Method | Description |
1283
+ |---|---|
1284
+ | `history.list(docId, opts?)` | Normalised entries (v1 + v2). Real Firestore pagination via `cursor`/`limit`. |
1285
+ | `history.raw(docId, opts?)` | Raw documents, no normalisation — escape hatch. |
1286
+ | `history.byField(docId, field, opts?)` | Convenience filter on a specific field. |
1287
+ | `history.byOperation(docId, operation, opts?)` | Filter by `create` / `update` / `delete`. |
1288
+ | `history.recordManual(docId, payload)` | Synchronous capture (bypasses trigger). Use sparingly. |
1289
+
1290
+ The unified `HistoryEntry<Model>`:
1291
+
1292
+ ```ts
1293
+ type HistoryEntry<T> = {
1294
+ historyDocId: string;
1295
+ historyToObjectId: string;
1296
+ historySetAt: Timestamp;
1297
+ schemaVersion: 1 | 2;
1298
+ operation: "create" | "update" | "delete";
1299
+ meta: { userId?, userEmail?, reason?, comment?, extras? };
1300
+ changes: { [field]: { oldValue, newValue, type: { old, new } } };
1301
+ };
1302
+ ```
1303
+
1304
+ ## TTL
1305
+
1306
+ History grows linearly with writes. Set `history.ttl: { field: "expiresAt", days: 365 }` to add a Timestamp on every doc, then enable a Firestore TTL policy on that field once via the gcloud CLI or console:
1307
+
1308
+ ```bash
1309
+ gcloud firestore fields ttls update expiresAt \
1310
+ --collection-group=history --enable-ttl
1311
+ ```
1312
+
1313
+ ## Backward compatibility (v1)
1314
+
1315
+ If your project already writes the legacy v1 schema (1 doc per field, top-level `field`/`changes`/`historyUserId`), the reader detects it and:
1316
+
1317
+ - Wraps each v1 doc into a 1-field unified entry.
1318
+ - Groups consecutive v1 docs sharing the same `historySetAt` (±5 ms by default) and same author into a single logical entry.
1319
+ - Maps `historyUserId` / `historyUserEmail` / `extraHistoryDetails.{reason,comment}` / `historyDetails` to the unified `meta`.
1320
+
1321
+ The trigger always writes v2. v1 is read-only — no migration is required to start using the new API.
1322
+
1323
+ ## Cost & limits
1324
+
1325
+ - 1 extra Firestore write per tracked update (the history doc). Use `exclude` and `include` to keep diffs lean.
1326
+ - Firestore doc size limit (1 MiB). Large field values are truncated with a `_truncated: true` marker (~700 KiB threshold).
1327
+ - Triggers run after the write commits — capture is reliable but **not** atomic with the parent write.
1328
+
1329
+ ---
1330
+
1331
+ # Firestore → SQL Sync
1332
+
1333
+ Automatically replicate Firestore collections to a SQL database (BigQuery, etc.) via Cloud Pub/Sub.
1334
+
1335
+ ## Architecture
1336
+
1337
+ ```
1338
+ Firestore Triggers → Cloud Pub/Sub → Worker → SQL Database
1339
+ (onCreate/onUpdate/onDelete) (BigQuery, etc.)
1340
+ ```
1341
+
1342
+ Each document change in Firestore publishes a message to a per-repo Pub/Sub topic.
1343
+ A worker subscribes to these topics, batches the changes, and flushes them to SQL.
1344
+
1345
+ ## Quick Start
1346
+
1347
+ ```typescript
1348
+ import { createServers } from "@lpdjs/firestore-repo-service";
1349
+ import { BigQueryAdapter } from "@lpdjs/firestore-repo-service/sync/bigquery";
1350
+ import { BigQuery } from "@google-cloud/bigquery";
1351
+ import { PubSub } from "@google-cloud/pubsub";
1352
+ import * as firestoreTriggers from "firebase-functions/v2/firestore";
1353
+ import * as pubsubHandler from "firebase-functions/v2/pubsub";
1354
+ import { onRequest } from "firebase-functions/v2/https";
1355
+
1356
+ const servers = createServers(repos, { onRequest });
1357
+
1358
+ const sync = servers.sync({
1359
+ deps: { firestoreTriggers, pubsubHandler, pubsub: new PubSub() },
1360
+ adapter: new BigQueryAdapter({
1361
+ bigquery: new BigQuery({
1362
+ projectId: "my-project",
1363
+ location: "us-central1",
1364
+ }),
1365
+ datasetId: "firestore_sync",
1366
+ }),
1367
+ topicPrefix: "firestore-sync",
1368
+ autoMigrate: true,
1369
+ admin: {
1370
+ httpsOptions: { invoker: "public" },
1371
+ auth: { type: "basic", username: "admin", password: "secret" },
1372
+ featuresFlag: {
1373
+ healthCheck: true,
1374
+ manualSync: true,
1375
+ configCheck: true,
1376
+ },
1377
+ },
1378
+ repos: {
1379
+ users: {
1380
+ exclude: ["sensitiveField"],
1381
+ columnMap: { docId: "user_id" },
1382
+ tableName: "users",
1383
+ },
1384
+ posts: { columnMap: { docId: "post_id" } },
1385
+ },
1386
+ });
1387
+
1388
+ // Export triggers + PubSub handlers
1389
+ export const {
1390
+ users_onCreate,
1391
+ users_onUpdate,
1392
+ users_onDelete,
1393
+ sync_users,
1394
+ posts_onCreate,
1395
+ posts_onUpdate,
1396
+ posts_onDelete,
1397
+ sync_posts,
1398
+ adminsync,
1399
+ } = sync.functions;
1400
+ ```
1401
+
1402
+ > The shared `onRequest` is automatically forwarded to the sync admin so the bundled `adminsync` Cloud Function is generated for you. You only need to pass `admin.onRequest` explicitly if you want to override it.
1403
+
1404
+ ## Configuration
1405
+
1406
+ ### `createServers(repos).sync(config)`
1407
+
1408
+ The unified wrapper that creates triggers, workers, and the optional admin server (using the repository registry already bound to `createServers`).
1409
+
1410
+ | Option | Type | Default | Description |
1411
+ | ----------------- | ---------------------- | ------------------ | --------------------------------------------------------------- |
1412
+ | `deps` | `SyncDeps` | required | Firebase Functions + PubSub dependencies |
1413
+ | `adapter` | `SqlAdapter` | required | SQL adapter (e.g. `BigQueryAdapter`) |
1414
+ | `topicPrefix` | `string` | `"firestore-sync"` | Pub/Sub topic name prefix |
1415
+ | `batchSize` | `number` | `100` | Max rows per flush batch |
1416
+ | `flushIntervalMs` | `number` | `5000` | Flush interval in ms |
1417
+ | `autoMigrate` | `boolean` | `false` | Auto-create/migrate tables on first event |
1418
+ | `workerOptions` | `SyncWorkerOptions` | — | CF v2 options for the worker (`concurrency`, `maxInstances`, …) |
1419
+ | `admin` | `adminsyncConfig` | — | Optional admin endpoint config |
1420
+ | `repos` | `TypedRepoSyncConfigs` | — | Per-repo overrides |
1421
+
1422
+ ### Dependencies (`deps`)
1423
+
1424
+ All Firebase/GCP modules are injected — the library never imports them directly:
1425
+
1426
+ ```typescript
1427
+ deps: {
1428
+ firestoreTriggers, // firebase-functions/v2/firestore
1429
+ pubsubHandler, // firebase-functions/v2/pubsub
1430
+ pubsub: new PubSub({ projectId: "my-project" }),
1431
+ }
1432
+ ```
1433
+
1434
+ **Lazy initialization**
1435
+ `deps.pubsub` and `adapter` both accept a factory function `() => T` for lazy initialization.
1436
+ This avoids creating gRPC channels or BigQuery connections at module-load time for Cloud Functions
1437
+ that don't need them (e.g. HTTP-only functions sharing the same deploy).
1438
+
1439
+ ```typescript
1440
+ deps: { firestoreTriggers, pubsubHandler, pubsub: () => new PubSub() },
1441
+ adapter: () => new BigQueryAdapter({ bigquery: new BigQuery(), datasetId: "sync" }),
1442
+ ```
1443
+
1444
+ ### Per-Repo Config (`repos`)
1445
+
1446
+ | Option | Type | Description |
1447
+ | ------------- | ------------------------ | ------------------------------------------------------------------- |
1448
+ | `tableName` | `string` | SQL table name (defaults to repo name) |
1449
+ | `exclude` | `string[]` | Fields to exclude from SQL |
1450
+ | `columnMap` | `Record<string, string>` | Rename fields → SQL columns |
1451
+ | `triggerPath` | `string` | **Required for collection groups** — the full document path pattern |
1452
+
1453
+ ### Collection Groups (`triggerPath`)
1454
+
1455
+ For repos with `isGroup: true`, you **must** provide a `triggerPath`:
1456
+
1457
+ ```typescript
1458
+ repos: {
1459
+ comments: {
1460
+ triggerPath: "posts/{postId}/comments/{docId}",
1461
+ tableName: "comments",
1462
+ },
1463
+ }
1464
+ ```
1465
+
1466
+ This tells Firebase where to listen for document changes since collection groups span multiple paths.
1467
+
1468
+ ## Out-of-Order Delivery Protection
1469
+
1470
+ Pub/Sub does **not** guarantee message order, and Cloud Functions v2 deliberately
1471
+ exposes no way to enable `enableMessageOrdering` on the auto-created push subscription
1472
+ behind `onMessagePublished`. For Firestore sync this means rapid successive writes to
1473
+ the same document (e.g. `create` then `update`) could otherwise be flushed to SQL out
1474
+ of order, leaving stale data.
1475
+
1476
+ The library handles this **at the application level**:
1477
+
1478
+ 1. Every `SyncEvent` published by a trigger carries a `version` field — the publish
1479
+ time `Date.now()` in milliseconds.
1480
+ 2. The worker stamps the row with this value in a hidden `__sync_version` column
1481
+ (auto-added by `zodSchemaToColumns` and `autoMigrate`).
1482
+ 3. The BigQuery `MERGE` only updates the row when the incoming version is strictly
1483
+ greater than the stored one:
1484
+
1485
+ ```sql
1486
+ WHEN MATCHED
1487
+ AND (T.`__sync_version` IS NULL OR S.`__sync_version` > T.`__sync_version`)
1488
+ THEN UPDATE SET …
1489
+ ```
1490
+
1491
+ 4. Within a single batch, the queue dedupes upserts per `docId` keeping only the row
1492
+ with the highest `version` — which avoids the BigQuery error
1493
+ _"UPDATE/MERGE must match at most one source row for each target row"_ when several
1494
+ updates to the same document are flushed together.
1495
+
1496
+ **You don't need to configure anything.** Out-of-order updates are silently dropped,
1497
+ the most recent write always wins. Existing tables get the `__sync_version` column
1498
+ added automatically on the next worker invocation when `autoMigrate: true`.
1499
+
1500
+ **Older deployments**
1501
+ Rows that pre-date this version have `__sync_version = NULL`. The MERGE treats `NULL`
1502
+ as "always update", so the first incoming event after upgrade fills it in. After that
1503
+ the version comparison kicks in normally.
1504
+
1505
+ **DELETE races**
1506
+ A `DELETE` event arriving after a newer `UPSERT` for the same document **will** delete
1507
+ the row. Firestore deletes are usually terminal so this is rarely a problem in practice,
1508
+ but if your domain re-creates documents under the same id you should add an
1509
+ application-level tombstone column.
1510
+
1511
+ ## BigQuery Topic & Subscription Setup
1512
+
1513
+ You don't need to pre-create anything. On first deploy:
1514
+
1515
+ - Cloud Functions v2 creates the trigger topic (`{topicPrefix}-{repoName}`) via Eventarc.
1516
+ - The worker creates the dead-letter topic (`{topicPrefix}-{repoName}-dlq`) the first
1517
+ time a flush fails.
1518
+
1519
+ **Why the library doesn't pre-create subscriptions**
1520
+ A previous version exposed an `ensureSyncInfra` helper that created pull subscriptions
1521
+ with `enableMessageOrdering: true`. It was a dead-end — Cloud Functions v2 ignores
1522
+ pre-created subscriptions and always uses its own Eventarc-managed push subscription.
1523
+ The helper has been removed in favour of application-level versioning (see above).
1524
+
1525
+ ## Tuning & Scaling
1526
+
1527
+ Three knobs let you trade latency, throughput and BigQuery quota pressure:
1528
+
1529
+ | Option | Where | Default | What it controls |
1530
+ | ----------------- | ---------------- | ------- | --------------------------------------------------------------------------- |
1531
+ | `batchSize` | top-level config | `100` | Max rows merged per BigQuery `MERGE` statement |
1532
+ | `flushIntervalMs` | top-level config | `5000` | Max time a row sits in the in-memory queue before being flushed |
1533
+ | `workerOptions` | top-level config | — | Cloud Functions v2 options for every worker handler (concurrency, scaling…) |
1534
+
1535
+ ```typescript
1536
+ createServers(repos).sync({
1537
+ // ...
1538
+ batchSize: 500, // bigger batches → fewer DML statements → less quota pressure
1539
+ flushIntervalMs: 10_000, // wait longer to fill batches (higher latency, higher throughput)
1540
+ workerOptions: {
1541
+ concurrency: 5, // process up to 5 messages in parallel per instance
1542
+ maxInstances: 1, // ⚠️ keep at 1 per repo to avoid BigQuery serialize-access errors
1543
+ minInstances: 0, // set to 1 to avoid cold starts (costs ~$5-15/mo)
1544
+ memory: "512MiB",
1545
+ timeoutSeconds: 120,
1546
+ region: "europe-west1",
1547
+ retry: true, // PubSub retries on thrown error → no event loss
1548
+ },
1549
+ });
1550
+ ```
1551
+
1552
+ `workerOptions` is forwarded as-is to `onMessagePublished({ topic, ...workerOptions }, …)`.
1553
+ Any [`PubSubOptions`](https://firebase.google.com/docs/reference/functions/2nd-gen/node/firebase-functions.v2.pubsub.pubsuboptions)
1554
+ field is accepted (`cpu`, `vpcConnector`, `serviceAccount`, `secrets`, etc.).
1555
+
1556
+ ### Concurrency & PubSub ack semantics
1557
+
1558
+ Each repo gets its own `SyncQueue` shared across all in-instance invocations
1559
+ (it lives in the worker's module closure). When `concurrency > 1`, several
1560
+ PubSub messages are handled in parallel **inside the same Node.js process**
1561
+ and all enqueue into the same buffer.
1562
+
1563
+ `SyncQueue.flush()` coalesces concurrent callers: every parallel handler
1564
+ awaits the same in-flight write and only resolves once its event has
1565
+ actually been persisted. This is what makes `await q.flush()` at the end
1566
+ of the handler safe — PubSub only acks after BigQuery confirmed the write,
1567
+ so an instance crash before flush never loses an event.
1568
+
1569
+ **Dead-letter & infinite retry protection**
1570
+
1571
+ `onFlushError` re-publishes failed events to `{topicPrefix}-{repoName}-dlq`
1572
+ and re-throws if that publish itself fails — PubSub then redelivers the
1573
+ original message instead of acking. To avoid an infinite redelivery loop on
1574
+ a poison message, configure a **dead-letter policy on the PubSub
1575
+ subscription** (Cloud Functions v2 / Eventarc subscription) with e.g.
1576
+ `maxDeliveryAttempts: 5`. Events are idempotent thanks to the
1577
+ `__sync_version` column, so retries never corrupt data.
1578
+
1579
+ **Recommended defaults for production**
1580
+
1581
+ - Low traffic (< 10 writes/s/repo): `batchSize: 100`, `flushIntervalMs: 5_000`,
1582
+ `concurrency: 5`, `maxInstances: 1`.
1583
+ - Medium (10-100 writes/s/repo): `batchSize: 500`, `flushIntervalMs: 10_000`,
1584
+ `concurrency: 20`, `maxInstances: 3`.
1585
+ - High (> 100 writes/s/repo): `batchSize: 500–1000`, `flushIntervalMs: 10_000`,
1586
+ `concurrency: 40`, `maxInstances: 5+` — the Storage Write API has no
1587
+ per-table concurrency cap, so scale horizontally as needed.
1588
+ :::
1589
+
1590
+ ## BigQuery Adapter
1591
+
1592
+ The library ships a single BigQuery adapter that streams rows through the
1593
+ **BigQuery Storage Write API** in **CDC mode** (Change Data Capture).
1594
+ Multiple Cloud Function instances can write in parallel with no
1595
+ concurrency cap, it is ~50% cheaper than legacy streaming inserts, and
1596
+ out-of-order events are deduplicated by `_CHANGE_SEQUENCE_NUMBER` derived
1597
+ from each event's `__sync_version`.
1598
+
1599
+ The Storage Write client is an **optional peer dependency** — install it
1600
+ in your functions project:
1601
+
1602
+ ```bash
1603
+ npm install @google-cloud/bigquery-storage @google-cloud/bigquery
1604
+ ```
1605
+
1606
+ ```typescript
1607
+ import { BigQuery } from "@google-cloud/bigquery";
1608
+ import { BigQueryAdapter } from "@lpdjs/firestore-repo-service/sync/bigquery";
1609
+
1610
+ const adapter = new BigQueryAdapter({
1611
+ projectId: "my-project",
1612
+ datasetId: "firestore_sync",
1613
+ bigquery: new BigQuery({ projectId: "my-project" }),
1614
+ // Background CDC merge cadence — see "About maxStaleness" below.
1615
+ maxStaleness: "INTERVAL 15 MINUTE",
1616
+ });
1617
+ ```
1618
+
1619
+ The adapter handles:
1620
+
1621
+ - Table creation via DDL with `PRIMARY KEY ... NOT ENFORCED` and clustering
1622
+ on the PK (required by CDC mode)
1623
+ - Streaming UPSERTs and DELETEs through the default stream (at-least-once,
1624
+ no stream finalization needed)
1625
+ - Schema introspection (for health checks)
1626
+ - Automatic column migration (`addColumns`) with type-drift detection
1627
+ - ISO 8601 strings and `Date` instances in `TIMESTAMP` columns are encoded
1628
+ as epoch microseconds (the wire format the Storage Write API expects)
1629
+
1630
+ ### Authentication
1631
+
1632
+ - **Production (Cloud Run / Cloud Functions)**: credentials are automatic via ADC — just pass `projectId`
1633
+ - **Local development**: run `gcloud auth application-default login`
1634
+ - The service account needs `bigquery.tables.updateData` (granted by
1635
+ `roles/bigquery.dataEditor`)
1636
+
1637
+ ### About `maxStaleness`
1638
+
1639
+ CDC writes land in BigQuery's **change buffer**; rows only become visible
1640
+ in the base table once an asynchronous **MERGE** applies the buffer.
1641
+ `max_staleness` is the SLO for that merge:
1642
+
1643
+ - **`INTERVAL 0`** (BigQuery's silent default if you omit the option) —
1644
+ every `SELECT` triggers a synchronous merge of the entire buffer before
1645
+ returning results. Cheap-looking, but it makes reads slow and expensive
1646
+ on busy tables and defeats the point of CDC.
1647
+ - **`INTERVAL N MINUTE`** — BigQuery runs the MERGE in the background at
1648
+ most every N minutes (free, doesn't block reads). Reads against the
1649
+ table see data up to N minutes stale. The library defaults to
1650
+ **15 minutes** — a good production tradeoff between cost and freshness.
1651
+ - For development you can set `INTERVAL 1 MINUTE` if you need to see your
1652
+ writes quickly in the BigQuery UI.
1653
+
1654
+ ### Migrating tables created by older versions of this library
1655
+
1656
+ Tables originally created by the legacy MERGE-based adapter (≤ 2.3.x) do
1657
+ not have a PK constraint. Before deploying, run once per table:
1658
+
1659
+ ```sql
1660
+ ALTER TABLE `dataset.posts`
1661
+ ADD PRIMARY KEY (post_id) NOT ENFORCED,
1662
+ SET OPTIONS (max_staleness = INTERVAL 15 MINUTE);
1663
+ ```
1664
+
1665
+ (Clustering can only be set at table creation; if your table is not
1666
+ clustered on the PK, recreate it with `CREATE TABLE … AS SELECT …` or
1667
+ accept the read-time penalty — Storage Write CDC still works.)
1668
+
1669
+ ## Schema Evolution
1670
+
1671
+ `autoMigrate` adds columns when your Zod schema gains fields. It **never**
1672
+ changes the type of an existing column — BigQuery itself only allows narrow
1673
+ widenings (`INT64 → NUMERIC → BIGNUMERIC`, `DATE → DATETIME → TIMESTAMP`),
1674
+ and a wrong implicit conversion would silently corrupt data.
1675
+
1676
+ Starting with v2.3.x the worker therefore detects type drift and throws
1677
+ `SchemaTypeMismatchError` on the first event:
1678
+
1679
+ ```
1680
+ Schema drift detected on `posts`: column `view_count` has type STRING in
1681
+ BigQuery but the current Zod schema maps it to INT64. BigQuery cannot
1682
+ safely convert between these types — to resolve, either (a) keep the
1683
+ BigQuery type and add a transform in your repo to coerce values,
1684
+ (b) rename the field in your Zod schema (creates a new column), or
1685
+ (c) drop & recreate the table.
1686
+ ```
1687
+
1688
+ This is a **fail-fast** by design: the alternative is every flush failing
1689
+ with a cryptic cast error, the dead-letter queue filling up, and PubSub
1690
+ retrying forever.
1691
+
1692
+ ### Recommended workflow
1693
+
1694
+ Treat Firestore document schemas as **append-only**. When you must change
1695
+ the type of a field:
1696
+
1697
+ 1. **Rename the field in Zod** (`view_count` → `view_count_v2`). The next
1698
+ migration adds the new column; old rows keep `NULL` until backfilled.
1699
+ 2. **Backfill** with a one-off SQL job: `UPDATE … SET view_count_v2 = CAST(view_count AS INT64)`.
1700
+ 3. **Drop the old column** once writes have moved over.
1701
+
1702
+ If you really need to mutate a column in-place, do it manually before
1703
+ deploying the new code (`ALTER TABLE x ALTER COLUMN y SET DATA TYPE …` —
1704
+ allowed only for the widenings BigQuery accepts).
1705
+
1706
+ ## Sync Admin
1707
+
1708
+ The optional admin endpoint provides a web UI for monitoring and managing the sync pipeline.
1709
+
1710
+ ### Features
1711
+
1712
+ | Feature | Flag | Description |
1713
+ | ---------------- | ------------- | --------------------------------------------------------------------- |
1714
+ | **Health Check** | `healthCheck` | Compare expected schema (from Zod) vs actual SQL columns |
1715
+ | **Force Sync** | `manualSync` | Re-sync all documents from a Firestore collection |
1716
+ | **Config Check** | `configCheck` | Verify GCP APIs, topics, tables, and IAM — with `gcloud` fix commands |
1717
+
1718
+ ### Configuration
1719
+
1720
+ ```typescript
1721
+ admin: {
1722
+ auth: {
1723
+ type: "basic",
1724
+ realm: "Sync Admin",
1725
+ username: "admin",
1726
+ password: process.env.SYNC_ADMIN_PASSWORD!,
1727
+ },
1728
+ basePath: "/",
1729
+ featuresFlag: {
1730
+ healthCheck: true,
1731
+ manualSync: true,
1732
+ configCheck: true,
1733
+ },
1734
+ }
1735
+ ```
1736
+
1737
+ ### Authentication
1738
+
1739
+ Same as the Admin Server — supports HTTP Basic Auth, a Firebase `AuthExtension`,
1740
+ or a custom middleware function:
1741
+
1742
+ ```typescript
1743
+ // Custom middleware
1744
+ admin: {
1745
+ auth: async (req, res, next) => {
1746
+ const token = req.headers["x-api-key"];
1747
+ if (token !== process.env.API_KEY) {
1748
+ res.status(401).send("Unauthorized");
1749
+ return;
1750
+ }
1751
+ next();
1752
+ },
1753
+ }
1754
+ ```
1755
+
1756
+ #### Firebase Auth (unified with admin / crud)
1757
+
1758
+ The `auth` field also accepts the `AuthExtension` returned by
1759
+ `firebaseAuth({ ... })` — the same one used by `servers.admin()` and
1760
+ `servers.crud()`. The inline login page, session cookies and `allow()`
1761
+ callback work identically:
1762
+
1763
+ ```typescript
1764
+ import { firebaseAuth } from "@lpdjs/firestore-repo-service/servers/auth";
1765
+ import { getAuth } from "firebase-admin/auth";
1766
+
1767
+ admin: {
1768
+ auth: firebaseAuth({
1769
+ getAuth: () => getAuth(),
1770
+ mode: "cookie",
1771
+ apiKey: process.env.FIREBASE_WEB_API_KEY!,
1772
+ authDomain: process.env.FIREBASE_AUTH_DOMAIN!,
1773
+ allow: ({ claims }) =>
1774
+ claims.role === "superAdmin" ? { role: "superAdmin" } : null,
1775
+ }),
1776
+ featuresFlag: { healthCheck: true, configCheck: true },
1777
+ }
1778
+ ```
1779
+
1780
+ ### Config Check
1781
+
1782
+ The `/config-check` endpoint verifies your GCP setup:
1783
+
1784
+ - **BigQuery API** — is it enabled and accessible?
1785
+ - **BigQuery tables** — does each repo table exist?
1786
+ - **Pub/Sub topics** — does each `{topicPrefix}-{repoName}` topic exist?
1787
+
1788
+ For each issue, it shows:
1789
+
1790
+ - A `gcloud` command to fix it
1791
+ - A direct link to the GCP Console
1792
+
1793
+ Supports `Accept: application/json` for programmatic use.
1794
+
1795
+ ### Deploying the Admin
1796
+
1797
+ The admin handler is auto-wrapped when `onRequest` is provided in the config.
1798
+ Pass `httpsOptions` to configure the Cloud Function (invoker, memory, region, etc.):
1799
+
1800
+ ```typescript
1801
+ admin: {
1802
+ onRequest,
1803
+ httpsOptions: { invoker: "public", memory: "512MiB" },
1804
+ auth: { type: "basic", username: "admin", password: "secret" },
1805
+ featuresFlag: { healthCheck: true, configCheck: true },
1806
+ }
1807
+ ```
1808
+
1809
+ The handler is then available in `sync.functions.adminsync` — already wrapped as a Cloud Function.
1810
+
1811
+ If you omit `onRequest`, the raw handler is exposed and you wrap it manually:
1812
+
1813
+ ```typescript
1814
+ import { onRequest } from "firebase-functions/v2/https";
1815
+
1816
+ export const adminsync = onRequest({ invoker: "public" }, sync.adminHandler!);
1817
+ ```
1818
+
1819
+ ### Force Sync
1820
+
1821
+ Triggered from the admin dashboard or via `POST /force-sync/{repo}` (HTML or
1822
+ `Accept: application/json`). It re-reads every document of a Firestore collection and
1823
+ republishes it through the sync pipeline.
1824
+
1825
+ The response includes:
1826
+
1827
+ | Field | Description |
1828
+ | -------------- | --------------------------------------------------- |
1829
+ | `processed` | Total documents read from Firestore |
1830
+ | `published` | Successful Pub/Sub publishes |
1831
+ | `errors` | Number of documents that failed to publish |
1832
+ | `errorSamples` | First 5 errors (`{ docId, message }`) for diagnosis |
1833
+
1834
+ Errors are also logged via `console.error('[ForceSync:{repo}] doc={docId} failed:', e)`
1835
+ so they appear in Cloud Logging.
1836
+
1837
+ ## Generated Functions
1838
+
1839
+ `servers.sync(...)` generates these Cloud Functions:
1840
+
1841
+ | Function | Type | Purpose |
1842
+ | ----------------- | ----------------- | ------------------------------------- |
1843
+ | `{repo}_onCreate` | Firestore trigger | Publish UPSERT on document create |
1844
+ | `{repo}_onUpdate` | Firestore trigger | Publish UPSERT on document update |
1845
+ | `{repo}_onDelete` | Firestore trigger | Publish DELETE on document delete |
1846
+ | `sync_{repo}` | PubSub handler | Process messages and flush to SQL |
1847
+ | `adminsync` | HTTP handler | Admin UI (if `admin` config provided) |
1848
+
1849
+ ## Schema Mapping
1850
+
1851
+ Zod schemas are automatically mapped to SQL types:
1852
+
1853
+ | Zod Type | BigQuery Type |
1854
+ | -------------------------- | ------------- |
1855
+ | `z.string()` | `STRING` |
1856
+ | `z.number()` | `FLOAT64` |
1857
+ | `z.bigint()` | `INT64` |
1858
+ | `z.boolean()` | `BOOL` |
1859
+ | `z.date()` | `TIMESTAMP` |
1860
+ | `z.object()` / `z.array()` | `JSON` |
1861
+
1862
+ ## Date Handling (`setDateHandling`)
1863
+
1864
+ Firestore returns dates as `Timestamp` objects. By default the library leaves them as
1865
+ `Timestamp` (mode `"preserve"`), which keeps full nanosecond precision but means
1866
+ consumers must call `.toDate()` themselves and Zod `z.date()` schemas will reject them.
1867
+
1868
+ Switch to `"normalize"` once at app startup to convert every `Timestamp` (including
1869
+ nested ones inside objects/arrays) to a JavaScript `Date` on read:
1870
+
1871
+ ```typescript
1872
+ import { setDateHandling } from "@lpdjs/firestore-repo-service";
1873
+
1874
+ setDateHandling("normalize");
1875
+ ```
1876
+
1877
+ | Mode | Behavior |
1878
+ | ------------- | --------------------------------------------------------------- |
1879
+ | `"preserve"` | (default) Firestore `Timestamp` instances are returned as-is |
1880
+ | `"normalize"` | Recursively convert `Timestamp` → `Date` on every document read |
1881
+
1882
+ This is recommended when using the BigQuery sync (Zod `z.date()` → `TIMESTAMP`) so the
1883
+ schema validation and SQL serialization both see proper `Date` instances.
1884
+
1885
+ Helpers `coerceToDate(value)` and `normalizeTimestamps(value)` are also exported for
1886
+ manual conversion (e.g. inside custom mappers).
1887
+
1888
+ ## Custom SQL Adapter
1889
+
1890
+ Implement the `SqlAdapter` interface for other databases:
1891
+
1892
+ ```typescript
1893
+ import type {
1894
+ SqlAdapter,
1895
+ SqlDialect,
1896
+ SqlColumn,
1897
+ SqlTableDef,
1898
+ } from "@lpdjs/firestore-repo-service/sync";
1899
+
1900
+ class MyAdapter implements SqlAdapter {
1901
+ get dialect(): SqlDialect {
1902
+ /* ... */
1903
+ }
1904
+ async tableExists(tableName: string): Promise<boolean> {
1905
+ /* ... */
1906
+ }
1907
+ async getTableColumns(tableName: string): Promise<string[]> {
1908
+ /* ... */
1909
+ }
1910
+ async createTable(table: SqlTableDef): Promise<void> {
1911
+ /* ... */
1912
+ }
1913
+ async addColumns(tableName: string, columns: SqlColumn[]): Promise<void> {
1914
+ /* ... */
1915
+ }
1916
+ async insertRows(
1917
+ tableName: string,
1918
+ rows: Record<string, unknown>[],
1919
+ ): Promise<void> {
1920
+ /* ... */
1921
+ }
1922
+ async upsertRows(
1923
+ tableName: string,
1924
+ rows: Record<string, unknown>[],
1925
+ primaryKey: string,
1926
+ ): Promise<void> {
1927
+ /* ... */
1928
+ }
1929
+ async deleteRows(
1930
+ tableName: string,
1931
+ primaryKey: string,
1932
+ ids: string[],
1933
+ ): Promise<void> {
1934
+ /* ... */
1935
+ }
1936
+ async executeRaw(sql: string): Promise<void> {
1937
+ /* ... */
1938
+ }
1939
+ }
1940
+ ```
1941
+
1942
+ ---
1943
+
1944
+ # Hono API Server
1945
+
1946
+ A typed, file-based HTTP server built on [Hono](https://hono.dev/), designed
1947
+ to ship one (or many) **Firebase Cloud Function v2** per logical API. It pairs
1948
+ a tiny prebuild codegen (`frs gen`) with a typed multi-API registry
1949
+ (`createApiRegistry`) so you can write Zod-validated routes next to your
1950
+ business logic and forget about wiring.
1951
+
1952
+ ## Feature overview
1953
+
1954
+ | Feature | Description |
1955
+ | --- | --- |
1956
+ | **File-based routing** | Drop a `routes.ts` next to a useCase. The CLI scans the tree at build time and emits a static manifest — zero runtime filesystem access. |
1957
+ | **Multi-API registry** | `createApiRegistry({ v1, v2, webhooks, ... })` is the single source of truth. Each tag becomes one Cloud Function. |
1958
+ | **Typed `defineRoute` / `useCaseRoute`** | The `api` field is narrowed to your registered tags. `useCaseRoute(UseCaseClass, meta)` derives `input` / `output` from the useCase's static Zod schemas; `defineRoute({...})` stays available for inline handlers. |
1959
+ | **Zod validation** | `input` schemas validated automatically (body / query / params). Optional response validation via `validateOutput`. |
1960
+ | **OpenAPI 3.1** | Auto-generated from the Zod schemas. `/openapi.json` + interactive Scalar UI at `/docs`. |
1961
+ | **Interceptor + onError** | Single around-style hook per API for envelopes, error mapping, tracing. Plus a Hono-style `onError`. |
1962
+ | **Middlewares** | Per-API and per-route Hono middlewares with full type propagation. |
1963
+ | **Typed context** | Augment Hono's `ContextVariableMap` once and `c.get("user")` is fully typed in every handler. |
1964
+ | **CLI scaffolder** | `frs init` bootstraps `apis.ts` + manifest stub. `frs new` scaffolds a useCase + route + Vitest test (interactive prompts when flags are missing). |
1965
+ | **One function per API** | `apis.toFunctions(routes, onRequest, { defaults, per })` returns a map ready to spread into your `index.ts`. |
1966
+
1967
+ ## Install
1968
+
1969
+ ```bash
1970
+ npm i @lpdjs/firestore-repo-service hono @hono/node-server zod
1971
+ npm i -D @asteasolutions/zod-to-openapi
1972
+ ```
1973
+
1974
+ The `frs` CLI is exposed via the package's `bin` field.
1975
+
1976
+ ## Bootstrap a project
1977
+
1978
+ ```bash
1979
+ npx frs init
1980
+ ```
1981
+
1982
+ The interactive prompt asks for:
1983
+ - the **domain root** (default `src/domains`),
1984
+ - the **`apis.ts` location** (default `src/apis.ts`),
1985
+ - the list of **API tags** to register (default `v1`),
1986
+ - an optional shared **basePath**.
1987
+
1988
+ Pass `--yes` to skip prompts (CI mode), or any of `--root`, `--apis-file`,
1989
+ `--apis`, `--base-path`, `--force` to override.
1990
+
1991
+ After `init` you'll have:
1992
+
1993
+ ```
1994
+ src/
1995
+ ├── apis.ts ← createApiRegistry(...) + export defineRoute / useCaseRoute
1996
+ └── domains/
1997
+ └── __generated__/routes.ts ← empty stub (refreshed by `frs gen`)
1998
+ ```
1999
+
2000
+ ## Wire it in your Cloud Functions entrypoint
2001
+
2002
+ ```ts
2003
+ // src/index.ts
2004
+ import { onRequest } from "firebase-functions/v2/https";
2005
+ import { apis } from "./apis.js";
2006
+ import { routes } from "./domains/__generated__/routes.js";
2007
+
2008
+ export const { v1, v2 } = apis.toFunctions(routes, onRequest, {
2009
+ defaults: { region: "us-central1", invoker: "public" },
2010
+ per: {
2011
+ v2: { memory: "512MiB" },
2012
+ },
2013
+ });
2014
+ ```
2015
+
2016
+ Each registered API tag produces one Cloud Function whose name matches the key.
2017
+ URLs end up at `https://<region>-<project>.cloudfunctions.net/v1/...`.
2018
+
2019
+ ## Configure your APIs (`apis.ts`)
2020
+
2021
+ ```ts
2022
+ import { createApiRegistry } from "@lpdjs/firestore-repo-service/servers/hono";
2023
+ import { enrichUser } from "./middlewares/enrich-user.js";
2024
+
2025
+ export const apis = createApiRegistry({
2026
+ v1: {
2027
+ basePath: "/v1",
2028
+ middlewares: [enrichUser],
2029
+ openapi: {
2030
+ info: { title: "Public API", version: "1.0.0" },
2031
+ securitySchemes: {
2032
+ bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "JWT" },
2033
+ },
2034
+ security: [{ bearerAuth: [] }],
2035
+ },
2036
+ interceptor: async ({ next, c }) => {
2037
+ try {
2038
+ const data = await next();
2039
+ return c.json({ success: true, data, error: null });
2040
+ } catch (err) {
2041
+ // map domain errors → HTTP, rethrow others to onError
2042
+ throw err;
2043
+ }
2044
+ },
2045
+ onError: (err, c) => {
2046
+ console.error("Unhandled:", err);
2047
+ return c.json({ error: "Internal Server Error" }, 500);
2048
+ },
2049
+ validateOutput: process.env["NODE_ENV"] !== "production",
2050
+ verbose: process.env["NODE_ENV"] !== "production",
2051
+ },
2052
+ webhooks: {
2053
+ basePath: "/hooks",
2054
+ openapi: { info: { title: "Webhooks", version: "1.0.0" } },
2055
+ },
2056
+ });
2057
+
2058
+ // Re-export the typed helpers used in every routes.ts.
2059
+ export const defineRoute = apis.defineRoute;
2060
+ export const useCaseRoute = apis.useCaseRoute;
2061
+ ```
2062
+
2063
+ ## Write a route
2064
+
2065
+ ```bash
2066
+ npx frs new createPost --domain posts --method post --api v1
2067
+ ```
2068
+
2069
+ Generates:
2070
+
2071
+ ```
2072
+ src/domains/posts/useCases/createPost/
2073
+ ├── routes.ts ← maps the useCase to an HTTP endpoint
2074
+ ├── posts.createPost.useCase.ts ← business logic + Zod input/output schemas
2075
+ └── posts.createPost.useCase.test.ts ← Vitest skeleton
2076
+ ```
2077
+
2078
+ The useCase / test files are prefixed with `<domain>.<name>` so every file is
2079
+ unique across the project (Ctrl+P / fuzzy-finder friendly) instead of a sea of
2080
+ identical `useCase.ts`. `routes.ts` keeps its name — it is the scan anchor for
2081
+ `frs gen`.
2082
+
2083
+ The useCase owns its Zod `input` / `output` schemas (as `static` members, the
2084
+ single source of truth) and the business logic. The shared `services` container
2085
+ is injected by the `UseCase` base class via the constructor:
2086
+
2087
+ ```ts
2088
+ // src/domains/posts/useCases/createPost/posts.createPost.useCase.ts
2089
+ import { z } from "zod";
2090
+ import { UseCase } from "@lpdjs/firestore-repo-service/servers/hono";
2091
+ import type { Services } from "../../../../services.js";
2092
+
2093
+ const input = z.object({ title: z.string() });
2094
+ const output = z.object({ id: z.string() });
2095
+
2096
+ export class PostsCreatePostUseCase extends UseCase<typeof input, typeof output, Services> {
2097
+ static readonly input = input;
2098
+ static readonly output = output;
2099
+
2100
+ async execute(payload: z.infer<typeof input>): Promise<z.infer<typeof output>> {
2101
+ const user = this.services.ctx.c.get("user");
2102
+ return { id: `${user.id}:${payload.title}` };
2103
+ }
2104
+ }
2105
+ ```
2106
+
2107
+ `routes.ts` then wires the useCase into an endpoint with `useCaseRoute` — no
2108
+ schema duplication, no handler boilerplate:
2109
+
2110
+ ```ts
2111
+ // src/domains/posts/useCases/createPost/routes.ts
2112
+ import { defineRoutes } from "@lpdjs/firestore-repo-service/servers/hono";
2113
+ import { useCaseRoute } from "../../../../apis.js";
2114
+ import { PostsCreatePostUseCase } from "./posts.createPost.useCase.js";
2115
+
2116
+ export default defineRoutes([
2117
+ useCaseRoute(PostsCreatePostUseCase, {
2118
+ api: "v1", // ← typed: "v1" | "webhooks"
2119
+ method: "post",
2120
+ summary: "Create a post",
2121
+ tags: ["posts"],
2122
+ }),
2123
+ ]);
2124
+ ```
2125
+
2126
+ The URL is derived from the file path: `posts/useCases/createPost` →
2127
+ `/posts/createPost`. Combined with the `v1` basePath above and the function
2128
+ name, the final URL is `…/v1/v1/posts/createPost` (or `…/v1/posts/createPost`
2129
+ if you only set `basePath: "/"`). You can also set `path` explicitly in the
2130
+ `useCaseRoute` meta.
2131
+
2132
+ `frs new` prompts interactively when flags are missing (route name,
2133
+ domain, method, api, with-usecase, with-test). Pass `--yes` to accept defaults.
2134
+
2135
+ ### Need full control? Use `defineRoute`
2136
+
2137
+ When a route has no useCase (or you want the handler inline), `defineRoute`
2138
+ takes the schemas + handler directly:
2139
+
2140
+ ```ts
2141
+ import { z } from "zod";
2142
+ import { defineRoutes } from "@lpdjs/firestore-repo-service/servers/hono";
2143
+ import { defineRoute } from "../../../../apis.js";
2144
+
2145
+ export default defineRoutes([
2146
+ defineRoute({
2147
+ api: "v1",
2148
+ method: "post",
2149
+ input: z.object({ title: z.string() }),
2150
+ output: z.object({ id: z.string() }),
2151
+ handler: async ({ input }) => ({ id: input.title }),
2152
+ }),
2153
+ ]);
2154
+ ```
2155
+
2156
+ ### Same endpoint, several APIs
2157
+
2158
+ Add more entries to the `defineRoutes([...])` array — each `useCaseRoute`
2159
+ (or `defineRoute`) is typed independently:
2160
+
2161
+ ```ts
2162
+ export default defineRoutes([
2163
+ useCaseRoute(CreatePostUseCase, { api: "v1", method: "post", tags: ["posts"] }),
2164
+ useCaseRoute(CreatePostUseCase, { api: "v2", method: "post", tags: ["posts"] }),
2165
+ ]);
2166
+ ```
2167
+
2168
+ ## Refresh the manifest
2169
+
2170
+ ```bash
2171
+ npx frs gen --root src/domains
2172
+ ```
2173
+
2174
+ Wire it into `package.json` as a prebuild step:
2175
+
2176
+ ```json
2177
+ {
2178
+ "scripts": {
2179
+ "build": "frs gen --root src/domains && tsc -p tsconfig.build.json",
2180
+ "build:watch": "tsc -w -p tsconfig.build.json"
2181
+ }
2182
+ }
2183
+ ```
2184
+
2185
+ Useful flags: `--out`, `--routes-file`, `--skip`, `--casing kebab`, `--ext .js`,
2186
+ `--exclude`, `--silent`.
2187
+
2188
+ ## Typing `c.get("user")` etc.
2189
+
2190
+ Augment Hono's variable map once (anywhere in your project):
2191
+
2192
+ ```ts
2193
+ // src/types/hono.d.ts
2194
+ import "hono";
2195
+ declare module "hono" {
2196
+ interface ContextVariableMap {
2197
+ user: { id: string; name: string; email: string };
2198
+ }
2199
+ }
2200
+ ```
2201
+
2202
+ Then inside any handler / middleware, `c.get("user")` is fully typed — no
2203
+ generics to plumb through.
2204
+
2205
+ ## Services & dependency injection
2206
+
2207
+ Declare every singleton your project needs — repositories, SDK clients,
2208
+ loggers — **once** in a global container and let the server inject it into
2209
+ every handler, interceptor, cron job, trigger or test.
2210
+
2211
+ ### Why
2212
+
2213
+ Without DI, every route has to `new MyUseCase()` and forward `c` so the
2214
+ useCase can read `c.get("user")`. That's boilerplate-heavy and couples
2215
+ your business code to Hono.
2216
+
2217
+ With the built-in container:
2218
+
2219
+ - Each service is instantiated **lazily** on first access and cached for
2220
+ the process lifetime — ideal for Cloud Functions cold-start.
2221
+ - Inter-service dependencies are inferred by destructuring the factory
2222
+ argument — no manual wiring.
2223
+ - A built-in `ctx` service exposes the current request's Hono `Context`
2224
+ via `AsyncLocalStorage`, so useCases can read `this.services.ctx.c.get("user")`
2225
+ without ever receiving `c` as a parameter.
2226
+
2227
+ ### Declare the container (`src/services.ts`)
2228
+
2229
+ The container holds **shared infrastructure only** — SPIs, repositories,
2230
+ SDK clients, loggers. UseCases live *outside* the container: they `extend`
2231
+ the `UseCase` base class and receive the whole `services` container through
2232
+ the constructor (injected for you by `useCaseRoute`). That boundary keeps
2233
+ `Services` free of any reference back to itself (no circular type alias) and
2234
+ makes useCases trivial to unit-test with hand-rolled fakes.
2235
+
2236
+ ```ts
2237
+ import { createServices } from "@lpdjs/firestore-repo-service/servers/hono";
2238
+ import { PostRepo } from "./domains/posts/PostRepo.js";
2239
+ import { BigQuery } from "@google-cloud/bigquery";
2240
+
2241
+ export const services = createServices({
2242
+ postRepo: ({ ctx }) => new PostRepo(ctx),
2243
+ bigquery: () => new BigQuery({ projectId: "..." }),
2244
+ });
2245
+
2246
+ export type Services = typeof services;
2247
+ ```
2248
+
2249
+ > **Two provider forms:** factory `({ ctx, db }) => new PostRepo(db)`
2250
+ > (recommended, deps explicit) or class `postRepo: PostRepo` (auto-injects
2251
+ > the full proxy). Only use the class form for SPIs that don't import
2252
+ > `Services`, otherwise TypeScript will emit *"type alias refers to itself
2253
+ > circularly"* and infer `any`.
2254
+
2255
+ ### Wire it into the registry (`src/apis.ts`)
2256
+
2257
+ ```ts
2258
+ import { createApiRegistry } from "@lpdjs/firestore-repo-service/servers/hono";
2259
+ import { services } from "./services.js";
2260
+
2261
+ export const apis = createApiRegistry(
2262
+ {
2263
+ v1: { basePath: "/v1", openapi: { info: { title: "API", version: "1.0.0" } } },
2264
+ },
2265
+ { services },
2266
+ );
2267
+
2268
+ export const defineRoute = apis.defineRoute;
2269
+ export const useCaseRoute = apis.useCaseRoute;
2270
+ ```
2271
+
2272
+ ### Use services in a route
2273
+
2274
+ `useCaseRoute` wires the useCase to the endpoint and injects the shared
2275
+ `services` container automatically — the handler is generated for you:
2276
+
2277
+ ```ts
2278
+ import { defineRoutes } from "@lpdjs/firestore-repo-service/servers/hono";
2279
+ import { useCaseRoute } from "../../../../apis.js";
2280
+ import { CreatePostUseCase } from "./useCase.js";
2281
+
2282
+ export default defineRoutes([
2283
+ useCaseRoute(CreatePostUseCase, { api: "v1", method: "post", tags: ["posts"] }),
2284
+ ]);
2285
+ ```
2286
+
2287
+ Inside `defineRoute` (inline handlers) the same `services` proxy is available
2288
+ on the handler context if you ever need it directly:
2289
+
2290
+ ```ts
2291
+ defineRoute({
2292
+ api: "v1",
2293
+ method: "post",
2294
+ input: z.object({ title: z.string() }),
2295
+ output: z.object({ id: z.string() }),
2296
+ handler: async ({ input, services }) =>
2297
+ new CreatePostUseCase(services).execute(input),
2298
+ });
2299
+ ```
2300
+
2301
+ ### Read `this.services` inside a useCase
2302
+
2303
+ A useCase `extends UseCase<typeof input, typeof output, Services>`: the base
2304
+ class injects the **shared `services` container** via the constructor and the
2305
+ static schemas drive the typing of `execute`. Read deps through
2306
+ `this.services` — never store `Services` as a hand-written field type.
2307
+
2308
+ ```ts
2309
+ import { z } from "zod";
2310
+ import { UseCase } from "@lpdjs/firestore-repo-service/servers/hono";
2311
+ import type { Services } from "../../../../services.js";
2312
+
2313
+ const input = z.object({ title: z.string() });
2314
+ const output = z.object({ id: z.string() });
2315
+
2316
+ export class CreatePostUseCase extends UseCase<typeof input, typeof output, Services> {
2317
+ static readonly input = input;
2318
+ static readonly output = output;
2319
+
2320
+ async execute(payload: z.infer<typeof input>): Promise<z.infer<typeof output>> {
2321
+ const user = this.services.ctx.c.get("user");
2322
+ return this.services.postRepo.create({ ...payload, authorId: user.id });
2323
+ }
2324
+ }
2325
+ ```
2326
+
2327
+ ### Reuse services outside HTTP (cron, triggers, tests)
2328
+
2329
+ `services.ctx.c` throws when accessed outside a request. Wrap non-HTTP
2330
+ code paths in `withRequestContext` to supply a synthetic context, then
2331
+ instantiate the useCase with the shared `services` container:
2332
+
2333
+ ```ts
2334
+ import { withRequestContext } from "@lpdjs/firestore-repo-service/servers/hono";
2335
+ import { services } from "./services.js";
2336
+ import { CreatePostUseCase } from "./domains/posts/useCases/createPost/useCase.js";
2337
+
2338
+ export const dailyTask = onSchedule("every 24 hours", async () => {
2339
+ await withRequestContext({ c: fakeContext() }, async () => {
2340
+ await new CreatePostUseCase(services).execute({ title: "daily digest" });
2341
+ });
2342
+ });
2343
+ ```
2344
+
2345
+ In Vitest the useCase is just a class — no `withRequestContext` needed,
2346
+ inject a hand-rolled `services` fake:
2347
+
2348
+ ```ts
2349
+ import { CreatePostUseCase } from "./useCase.js";
2350
+ import type { Services } from "../../../../services.js";
2351
+
2352
+ it("creates a post", async () => {
2353
+ const services = {
2354
+ ctx: { c: { get: () => ({ id: "u1" }) } },
2355
+ postRepo: { create: async (p: any) => ({ id: "p1", ...p }) },
2356
+ } as unknown as Services;
2357
+
2358
+ const uc = new CreatePostUseCase(services);
2359
+ expect((await uc.execute({ title: "hello" })).id).toBe("p1");
2360
+ });
2361
+ ```
2362
+
2363
+ ### Async resources — lazy connections
2364
+
2365
+ Don't make factories `async` — they're sync by design. Instead, lazy-load
2366
+ async resources inside the service:
2367
+
2368
+ ```ts
2369
+ export class BigQueryService {
2370
+ private _client: BigQuery | undefined;
2371
+ get client(): BigQuery {
2372
+ return (this._client ??= new BigQuery({ projectId: "..." }));
2373
+ }
2374
+ }
2375
+ ```
2376
+
2377
+ ### Scaffold a service
2378
+
2379
+ ```bash
2380
+ frs add service postRepo
2381
+ ```
2382
+
2383
+ Creates `src/services/postRepo.ts` and inserts an `import` + a factory
2384
+ line into `src/services.ts`. Pass `--services-file` / `--services-dir` if
2385
+ your layout differs.
2386
+
2387
+ ## OpenAPI
2388
+
2389
+ When `openapi.info` is set on an API, the server exposes:
2390
+
2391
+ - **`/<basePath>/openapi.json`** — the spec.
2392
+ - **`/<basePath>/docs`** — interactive [Scalar](https://scalar.com/) UI.
2393
+
2394
+ The UI's `data-url` is computed as a relative path so it works behind
2395
+ Firebase emulator's prefix rewriting and reverse proxies.
2396
+
2397
+ Because `@asteasolutions/zod-to-openapi` requires Zod to be patched first, the
2398
+ server calls `extendZodWithOpenApi(z)` automatically (idempotent) — your raw
2399
+ Zod schemas are picked up without ceremony.
2400
+
2401
+ ### Document the interceptor envelope & errors
2402
+
2403
+ By default the spec documents each route's raw `output`. But if an
2404
+ `interceptor` wraps responses (e.g. `{ data, intercepted: true }`), the real
2405
+ payload differs from `output`. Declare the envelope by using the **structured**
2406
+ interceptor form `{ output, errors?, handler }` — the generator then documents
2407
+ what the wrapper actually returns:
2408
+
2409
+ ```ts
2410
+ // apis.ts
2411
+ v1: {
2412
+ openapi: { info: { title: "API", version: "1.0.0" } },
2413
+ interceptor: {
2414
+ // factory — `data` reflects each route's own output schema in the docs
2415
+ output: (routeOutput) =>
2416
+ z.object({ data: routeOutput ?? z.unknown(), intercepted: z.boolean() }),
2417
+ // declared error responses, added to every operation
2418
+ errors: {
2419
+ 400: z.object({ success: z.literal(false), error: z.string() }),
2420
+ 500: { description: "Internal error", schema: z.object({ error: z.string() }) },
2421
+ },
2422
+ handler: async ({ c, next }) => c.json({ data: await next(), intercepted: true }),
2423
+ },
2424
+ },
2425
+ ```
2426
+
2427
+ - `output` accepts a **static** schema (same envelope for every route) **or** a
2428
+ **factory** `(routeOutput) => schema` (wraps each route's own `output`, so
2429
+ `data` stays precisely typed per endpoint).
2430
+ - `errors` keys are HTTP status codes; values are a bare Zod schema or
2431
+ `{ description?, schema? }`. They are added to every operation.
2432
+ - The bare-function form (`interceptor: async ({ next }) => …`) still works — it
2433
+ just produces no envelope metadata in the spec.
2434
+
2435
+ > Note: the interceptor is an opaque function at runtime, so the package cannot
2436
+ > infer its shape — `output` / `errors` are how you keep the docs in sync with
2437
+ > what the wrapper returns.
2438
+
2439
+ ### Centralized error handling (`errorHandler`)
2440
+
2441
+ Extend the package's **`BaseErrorHandler`** instead of repeating a `try/catch`
2442
+ everywhere. It already maps the built-in errors (`ValidationError`, …); override
2443
+ two hooks to plug your own domain errors + logger:
2444
+
2445
+ - `mapError(ctx)` → map your `AppError` to a `Response` (return `null` to defer);
2446
+ - `logError(ctx)` → log via your `AppLogger` (runs only when `mapError` matched).
2447
+
2448
+ Pass an instance **per API** so different APIs can use different strategies.
2449
+
2450
+ ```ts
2451
+ import {
2452
+ BaseErrorHandler,
2453
+ type ErrorHandlerContext,
2454
+ } from "@lpdjs/firestore-repo-service/servers/hono";
2455
+
2456
+ class AppErrorHandler extends BaseErrorHandler {
2457
+ protected override mapError({ error, c }: ErrorHandlerContext): Response | null {
2458
+ if (error instanceof AppError) {
2459
+ const locale = c.req.header("accept-language")?.startsWith("fr") ? "fr" : "en";
2460
+ return c.json(
2461
+ {
2462
+ // only expose the message when the error is user-facing
2463
+ error: error.userFacing ? error.localizedMessage[locale] : AppError.default(locale),
2464
+ errorId: error.errorId,
2465
+ },
2466
+ error.statusCode,
2467
+ );
2468
+ }
2469
+ return null; // → built-in mapping via super
2470
+ }
2471
+
2472
+ protected override logError({ error }: ErrorHandlerContext): void {
2473
+ AppLogger.err(error); // structured log + correlation id
2474
+ }
2475
+ }
2476
+
2477
+ // apis.ts — per API:
2478
+ v1: { ..., errorHandler: new AppErrorHandler() }, // user-facing, localized
2479
+ v2: { ..., errorHandler: new BaseErrorHandler() }, // defaults only, no user-facing constraints
2480
+ ```
2481
+
2482
+ - **Auto-applied**: thrown errors become proper HTTP responses with no
2483
+ interceptor boilerplate. If a custom interceptor rethrows, the handler still
2484
+ applies the `errorHandler`.
2485
+ - **Injected**: available as `errorHandler` in handler/interceptor ctx for
2486
+ manual use (`errorHandler?.handle({ error, c, route, services })`).
2487
+ - **Composable**: `mapError` returning `null` defers to the built-in mapping;
2488
+ unknown errors bubble to your `onError` / Hono.
2489
+ - **`userFacing` flag**: expose `localizedMessage` only when the error is meant
2490
+ for the client; otherwise return a generic message to avoid leaking internals.
2491
+ - A shared `errorHandler` can still be passed to `createApiRegistry(configs,
2492
+ { services, errorHandler })`; a per-API one overrides it.
2493
+
2494
+ #### Dev-only GCP log links (`gcpLogs`)
2495
+
2496
+ Pass `{ gcpLogs }` to `BaseErrorHandler` to turn a correlation id into a
2497
+ one-click **Cloud Logging "Logs Explorer"** deep link, so a developer can jump
2498
+ straight from an error response to its structured log. It is **opt-in** — keep
2499
+ it off in production (the link is for engineers, not end users).
2500
+
2501
+ ```ts
2502
+ class AppErrorHandler extends BaseErrorHandler {
2503
+ protected override mapError({ error, c }: ErrorHandlerContext): Response | null {
2504
+ if (error instanceof AppError) {
2505
+ const logsUrl = this.gcpLogsUrl(error.errorId); // undefined when disabled
2506
+ return c.json(
2507
+ { error: error.message, errorId: error.errorId, ...(logsUrl ? { logsUrl } : {}) },
2508
+ error.statusCode,
2509
+ );
2510
+ }
2511
+ return null;
2512
+ }
2513
+ }
2514
+
2515
+ // apis.ts — enable outside production; projectId defaults to env GOOGLE_CLOUD_PROJECT
2516
+ v1: {
2517
+ ...,
2518
+ errorHandler: new AppErrorHandler({
2519
+ gcpLogs: { enabled: process.env.NODE_ENV !== "production" },
2520
+ }),
2521
+ },
2522
+ ```
2523
+
2524
+ - The link filters on `jsonPayload.errorId="<id>"` — the field `BaseLogger`
2525
+ already writes — so it lands on the exact log line.
2526
+ - Options: `enabled` (master switch), `projectId` (defaults to
2527
+ `GOOGLE_CLOUD_PROJECT` / `GCLOUD_PROJECT` / `GCP_PROJECT`), `field` (default
2528
+ `errorId`), `duration` (optional lookback, e.g. `"PT1H"`).
2529
+ - The standalone `gcpLogsUrl(errorId, options)` / `resolveGcpProjectId()`
2530
+ helpers are exported too, for use outside the error handler.
2531
+
2532
+ ### Structured logging (`logger`)
2533
+
2534
+ Symmetric to `errorHandler`: extend the package's **`BaseLogger`** (override the
2535
+ single `write` hook to route to your sink — Firebase `logger`, pino, …) and pass
2536
+ an instance **per API**. It is injected into every handler / interceptor /
2537
+ error-handler context as `logger`.
2538
+
2539
+ ```ts
2540
+ import { BaseLogger, type LogSeverity } from "@lpdjs/firestore-repo-service/servers/hono";
2541
+ import { logger as fnLogger } from "firebase-functions/v2";
2542
+
2543
+ class AppLogger extends BaseLogger {
2544
+ protected override write(severity: LogSeverity, payload: Record<string, unknown>) {
2545
+ fnLogger.write({ severity, ...payload }); // one override covers info/warn/debug/error
2546
+ }
2547
+ }
2548
+ export const appLogger = new AppLogger();
2549
+
2550
+ // apis.ts — per API (or shared via createApiRegistry({ services, logger })):
2551
+ v1: { ..., logger: appLogger },
2552
+ ```
2553
+
2554
+ ```ts
2555
+ // in a handler / interceptor / errorHandler:
2556
+ handler: ({ input, logger }) => {
2557
+ logger?.info("creating post", { id: input.id });
2558
+ return { id: input.id };
2559
+ }
2560
+ ```
2561
+
2562
+ - `BaseLogger.error(err)` returns a **correlation id** (reuses `err.errorId`
2563
+ when present, else generates one) — log it and return it to the client.
2564
+ - Every level (`info` / `warn` / `debug` / `error`) funnels through `write`, so a
2565
+ single override is enough.
2566
+ - useCases only receive `services` (not the injected `logger`); expose the same
2567
+ instance via a project base class (`protected readonly logger = appLogger`) so
2568
+ `this.logger` and the injected `logger` are one and the same.
2569
+
2570
+ ### Protect the docs endpoints (`docsAuth`)
2571
+
2572
+ `openapi.docsAuth` guards **only** the `/docs` UI and `/openapi.json` spec — it
2573
+ never touches your API routes (those are protected per-API via `middlewares` or
2574
+ per-route interceptors). It accepts one Hono `MiddlewareHandler` or an array of
2575
+ them, so you can plug a fully custom flow or the built-in helpers.
2576
+
2577
+ Two helpers ship with the package:
2578
+
2579
+ ```ts
2580
+ import { getAuth } from "firebase-admin/auth";
2581
+ import {
2582
+ firebaseBearerAuth,
2583
+ basicAuth,
2584
+ } from "@lpdjs/firestore-repo-service/servers/hono";
2585
+
2586
+ // apis.ts — Firebase ID token (Bearer), with an optional allow() policy.
2587
+ v1: {
2588
+ openapi: {
2589
+ info: { title: "API", version: "1.0.0" },
2590
+ docsAuth: firebaseBearerAuth({
2591
+ getAuth: () => getAuth(), // lazy — runs after initializeApp()
2592
+ allow: (token) => token.admin === true, // optional, default: any verified user
2593
+ }),
2594
+ },
2595
+ },
2596
+
2597
+ // …or HTTP Basic Auth:
2598
+ docsAuth: basicAuth({ username: "admin", password: process.env.DOCS_PASSWORD! }),
2599
+
2600
+ // …or a fully custom flow (any Hono middleware):
2601
+ docsAuth: async (c, next) => {
2602
+ if (c.req.header("x-docs-key") !== process.env.DOCS_KEY) {
2603
+ return c.text("Unauthorized", 401);
2604
+ }
2605
+ return next();
2606
+ },
2607
+ ```
2608
+
2609
+ `firebaseBearerAuth` rejects a missing/invalid token with `401` and a failed
2610
+ `allow()` with `403`; the decoded token is stored on the context
2611
+ (`c.get("docsUser")` by default) for downstream use. `getAuth` is called lazily
2612
+ per request, so it is safe to declare before `initializeApp()` has run.
2613
+
2614
+ #### Login form + session cookie (`firebaseDocsAuth`)
2615
+
2616
+ For a browser-friendly flow — a **login page + session cookie**, exactly like
2617
+ the admin server — pass `firebaseDocsAuth(...)`. It returns a richer value (a
2618
+ `DocsAuthExtension`) that the server expands into the bundled
2619
+ `__login` / `__session` / `__logout` routes mounted next to the docs, plus the
2620
+ guard. Unauthenticated browsers are redirected to the login form; once signed
2621
+ in, an HttpOnly session cookie keeps them in.
2622
+
2623
+ ```ts
2624
+ import { getAuth } from "firebase-admin/auth";
2625
+ import { firebaseDocsAuth } from "@lpdjs/firestore-repo-service/servers/hono";
2626
+
2627
+ v1: {
2628
+ openapi: {
2629
+ info: { title: "API", version: "1.0.0" },
2630
+ docsAuth: firebaseDocsAuth({
2631
+ getAuth: () => getAuth(), // lazy — runs after initializeApp()
2632
+ apiKey: process.env.FIREBASE_API_KEY!, // Web app config (login page SDK)
2633
+ authDomain: process.env.FIREBASE_AUTH_DOMAIN!,
2634
+ allow: (token) => token.admin === true, // optional policy
2635
+ // mode: "both", // also accept a Bearer token (iframe)
2636
+ // providers: ["password", "google"], // login page providers
2637
+ }),
2638
+ },
2639
+ },
2640
+ ```
2641
+
2642
+ - **Modes**: `"cookie"` (default — login form) or `"both"` (also accept a
2643
+ `Bearer` ID token, handy to embed the docs in an authenticated iframe).
2644
+ - **Bundled routes** (`__login` / `__session` / `__logout`) are mounted as
2645
+ siblings of the docs page, so relative links/redirects survive any Cloud
2646
+ Functions / reverse-proxy path prefix.
2647
+ - **Options**: `allow`, `providers`, `title`, `cookieName`
2648
+ (default `__docs_session`), `sessionTtlDays` (default `5`), `secureCookie`
2649
+ (default `true`), `sameSite` (default `"Lax"`), `contextKey`
2650
+ (default `"docsUser"`), `onUnauthenticated` (`"redirect"` | `"401"`).
2651
+ - **Auth emulator**: the login page's client SDK targets the Auth emulator when
2652
+ `authEmulatorHost` is set (defaults to `FIREBASE_AUTH_EMULATOR_HOST`), matching
2653
+ the Admin SDK — so local `firebase emulators:start` sign-ins work end-to-end.
2654
+ Pass `authEmulatorHost: ""` to force production.
2655
+
2656
+ ## CLI reference
2657
+
2658
+ | Command | Purpose |
2659
+ | --- | --- |
2660
+ | `frs init` | Bootstrap `apis.ts` + an empty manifest stub. Interactive unless `--yes`. |
2661
+ | `frs gen --root <dir>` | Scan `<dir>` for `routes.ts` files and emit `__generated__/routes.ts`. |
2662
+ | `frs new <name> --domain <d>` | Scaffold a useCase + route + Vitest test. Prompts when flags are missing. |
2663
+ | `frs add service <name>` | Scaffold a service file and register it in `services.ts`. |
2664
+ | `frs add server <admin\|crud\|sync>` | Scaffold an ORM server (one file per server) + `repos.ts`/`servers.ts`. |
2665
+ | `frs sdk:spec --entry <module>` | Statically export the OpenAPI 3.1 spec to a JSON file (no server boot). |
2666
+
2667
+ Run `frs help` for the full flag list.
2668
+
2669
+ ### Static OpenAPI export (`frs sdk:spec`)
2670
+
2671
+ Export the OpenAPI 3.1 document to a file at build time — no server boot, no
2672
+ network — so a frontend can generate a typed SDK from it (openapi-typescript,
2673
+ orval, openapi-generator, …) and stay in sync with the API.
2674
+
2675
+ The CLI imports a **Node-importable** module (built JS, or run it with
2676
+ `bunx frs sdk:spec …` for TS) and reads the spec from an export that is either a
2677
+ **CRUD server** (its `.spec()` accessor — preserved even when wrapped via
2678
+ `onRequest`) or a plain **OpenAPI document**. For Hono, expose the doc via the
2679
+ registry's static `apis.spec(api, routes)`:
2680
+
2681
+ ```ts
2682
+ // export-openapi.ts (Hono)
2683
+ import { apis } from "./apis.js";
2684
+ import { routes } from "./domains/__generated__/routes.js";
2685
+ export const openapi = apis.spec("v1", routes);
2686
+ ```
2687
+
2688
+ ```bash
2689
+ # Hono — from the export above
2690
+ frs sdk:spec --entry lib/export-openapi.js --export openapi --out openapi.json
2691
+ # CRUD — auto-detects the `.spec()` server export
2692
+ frs sdk:spec --entry lib/crudServer.js --export api --out openapi.json
2693
+ ```
2694
+
2695
+ ### `.frsrc.json` — shared config
2696
+
2697
+ `frs init` writes a `.frsrc.json` at the project root so sibling commands can
2698
+ reuse the resolved layout instead of repeating flags:
2699
+
2700
+ ```json
2701
+ {
2702
+ "root": "src/domains",
2703
+ "apisFile": "src/apis.ts",
2704
+ "servicesFile": "src/services.ts",
2705
+ "apis": ["v1"]
2706
+ }
2707
+ ```
2708
+
2709
+ Every command reads this file and resolves each value with the precedence
2710
+ **flag → `.frsrc.json` → built-in default** — a flag is only applied when it is
2711
+ explicitly passed, otherwise the config value (if any) wins, then the default.
2712
+
2713
+ | Key | Type | Used by |
2714
+ | --- | --- | --- |
2715
+ | `root` | string | `gen` (`--root` becomes optional), `new` |
2716
+ | `out` | string | `gen` (output file) |
2717
+ | `apis` | string[] | `new` (first entry is the default `--api`) |
2718
+ | `useCaseFolder` | string | `new` |
2719
+ | `apisFile` / `servicesFile` / `servicesDir` | string | `add service` |
2720
+
2721
+ The file is optional: if it is missing or contains invalid JSON it is ignored
2722
+ silently. You can also edit it by hand.
2723
+
2724
+ ## Programmatic API (escape hatches)
2725
+
2726
+ The barrel `@lpdjs/firestore-repo-service/servers/hono` also exports:
2727
+
2728
+ - `HonoServer<TEnv>` — the underlying server class (use directly for
2729
+ custom mounts or unit tests).
2730
+ - `apis.serverFor(tag, routes)` — get the `HonoServer` for a specific API.
2731
+ - `buildOpenApiDocument(routes, options)` / `renderDocsHtml(...)` — generate
2732
+ the spec / UI HTML outside an HTTP context (e.g. in build scripts).
2733
+ - Codegen primitives: `scanRoutes`, `generateRoutesManifest`,
2734
+ `generateFromRoot`, `derivePath`, `toImportSpecifier` — for users who want
2735
+ to bypass the CLI and integrate directly into their own pipeline.
2736
+ - `ValidationError` — instance check inside your interceptor when you want
2737
+ to translate Zod failures into your own error envelope.