@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.
- package/dist/history/index.cjs +1 -1
- package/dist/history/index.cjs.map +1 -1
- package/dist/history/index.js +1 -1
- package/dist/history/index.js.map +1 -1
- package/dist/index.cjs +76 -76
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +8 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +76 -76
- package/dist/index.js.map +1 -1
- package/dist/llms-full.txt +2737 -0
- package/dist/llms.txt +120 -0
- package/dist/servers/admin/index.cjs +4 -4
- package/dist/servers/admin/index.cjs.map +1 -1
- package/dist/servers/admin/index.js +4 -4
- package/dist/servers/admin/index.js.map +1 -1
- package/dist/servers/hono/cli.cjs +171 -49
- package/dist/servers/hono/cli.cjs.map +1 -1
- package/dist/servers/hono/cli.js +171 -49
- package/dist/servers/hono/cli.js.map +1 -1
- package/dist/servers/hono/index.cjs +6 -6
- package/dist/servers/hono/index.cjs.map +1 -1
- package/dist/servers/hono/index.d.cts +13 -0
- package/dist/servers/hono/index.d.ts +13 -0
- package/dist/servers/hono/index.js +6 -6
- package/dist/servers/hono/index.js.map +1 -1
- package/dist/servers/index.cjs +85 -85
- package/dist/servers/index.cjs.map +1 -1
- package/dist/servers/index.js +85 -85
- package/dist/servers/index.js.map +1 -1
- package/dist/sync/index.cjs +46 -46
- package/dist/sync/index.cjs.map +1 -1
- package/dist/sync/index.js +46 -46
- package/dist/sync/index.js.map +1 -1
- package/package.json +4 -3
|
@@ -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.
|