@lpdjs/firestore-repo-service 2.4.1 → 2.5.2-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +92 -50
- package/dist/servers/hono/cli.cjs +127 -56
- package/dist/servers/hono/cli.cjs.map +1 -1
- package/dist/servers/hono/cli.js +127 -56
- package/dist/servers/hono/cli.js.map +1 -1
- package/dist/servers/hono/index.cjs +5 -5
- package/dist/servers/hono/index.cjs.map +1 -1
- package/dist/servers/hono/index.d.cts +213 -10
- package/dist/servers/hono/index.d.ts +213 -10
- package/dist/servers/hono/index.js +5 -5
- package/dist/servers/hono/index.js.map +1 -1
- package/dist/sync/bigquery.cjs +3 -3
- package/dist/sync/bigquery.cjs.map +1 -1
- package/dist/sync/bigquery.d.cts +34 -5
- package/dist/sync/bigquery.d.ts +34 -5
- package/dist/sync/bigquery.js +3 -3
- package/dist/sync/bigquery.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -102,20 +102,23 @@ if (post) {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
// Update
|
|
105
|
-
const updated = await repos.users.update("user123", {
|
|
105
|
+
const updated = await repos.users.update("user123", {
|
|
106
|
+
name: "New name",
|
|
107
|
+
age: 31,
|
|
108
|
+
});
|
|
106
109
|
```
|
|
107
110
|
|
|
108
111
|
## API reference
|
|
109
112
|
|
|
110
113
|
### `createRepositoryConfig()`
|
|
111
114
|
|
|
112
|
-
| Option | Description
|
|
113
|
-
| ------------- |
|
|
114
|
-
| `path` | Collection path in Firestore
|
|
115
|
-
| `isGroup` | `true` for collection group, `false` for simple
|
|
116
|
-
| `foreignKeys` | Keys for `get.by*` methods (single document lookup)
|
|
117
|
-
| `queryKeys` | Keys for `query.by*` methods (multi-document query)
|
|
118
|
-
| `refCb` | Function that returns the document reference
|
|
115
|
+
| Option | Description |
|
|
116
|
+
| ------------- | --------------------------------------------------- |
|
|
117
|
+
| `path` | Collection path in Firestore |
|
|
118
|
+
| `isGroup` | `true` for collection group, `false` for simple |
|
|
119
|
+
| `foreignKeys` | Keys for `get.by*` methods (single document lookup) |
|
|
120
|
+
| `queryKeys` | Keys for `query.by*` methods (multi-document query) |
|
|
121
|
+
| `refCb` | Function that returns the document reference |
|
|
119
122
|
|
|
120
123
|
**Sub-collection example:**
|
|
121
124
|
|
|
@@ -134,8 +137,8 @@ comments: createRepositoryConfig<CommentModel>()({
|
|
|
134
137
|
|
|
135
138
|
```typescript
|
|
136
139
|
interface QueryOptions<T> {
|
|
137
|
-
where?: [keyof T, WhereFilterOp, any][];
|
|
138
|
-
orWhere?: [keyof T, WhereFilterOp, any][][];
|
|
140
|
+
where?: [keyof T, WhereFilterOp, any][]; // AND conditions
|
|
141
|
+
orWhere?: [keyof T, WhereFilterOp, any][][]; // OR conditions
|
|
139
142
|
orderBy?: { field: keyof T; direction?: "asc" | "desc" }[];
|
|
140
143
|
limit?: number;
|
|
141
144
|
offset?: number;
|
|
@@ -226,7 +229,9 @@ const page = await repos.posts.query.paginate({
|
|
|
226
229
|
```typescript
|
|
227
230
|
import { count, sum, average } from "@lpdjs/firestore-repo-service";
|
|
228
231
|
|
|
229
|
-
const activeCount = await repos.users.aggregate.count({
|
|
232
|
+
const activeCount = await repos.users.aggregate.count({
|
|
233
|
+
where: [["isActive", "==", true]],
|
|
234
|
+
});
|
|
230
235
|
const totalViews = await repos.posts.aggregate.sum("views");
|
|
231
236
|
const avgAge = await repos.users.aggregate.average("age");
|
|
232
237
|
```
|
|
@@ -237,7 +242,9 @@ const avgAge = await repos.users.aggregate.average("age");
|
|
|
237
242
|
const result = await repos.users.transaction.run(async (txn) => {
|
|
238
243
|
const user = await txn.get(repos.users.documentRef("user123"));
|
|
239
244
|
if (user.exists()) {
|
|
240
|
-
txn.update(repos.users.documentRef("user123"), {
|
|
245
|
+
txn.update(repos.users.documentRef("user123"), {
|
|
246
|
+
age: user.data().age + 1,
|
|
247
|
+
});
|
|
241
248
|
}
|
|
242
249
|
return { success: true };
|
|
243
250
|
});
|
|
@@ -249,8 +256,14 @@ const result = await repos.users.transaction.run(async (txn) => {
|
|
|
249
256
|
// (status = 'active' AND age >= 18) OR (status = 'pending' AND verified = true)
|
|
250
257
|
const users = await repos.users.query.by({
|
|
251
258
|
orWhere: [
|
|
252
|
-
[
|
|
253
|
-
|
|
259
|
+
[
|
|
260
|
+
["status", "==", "active"],
|
|
261
|
+
["age", ">=", 18],
|
|
262
|
+
],
|
|
263
|
+
[
|
|
264
|
+
["status", "==", "pending"],
|
|
265
|
+
["verified", "==", true],
|
|
266
|
+
],
|
|
254
267
|
],
|
|
255
268
|
});
|
|
256
269
|
```
|
|
@@ -304,9 +317,9 @@ npm i -D @asteasolutions/zod-to-openapi
|
|
|
304
317
|
### Bootstrap
|
|
305
318
|
|
|
306
319
|
```bash
|
|
307
|
-
npx frs
|
|
308
|
-
npx frs
|
|
309
|
-
npx frs
|
|
320
|
+
npx frs init # interactive — creates apis.ts + manifest stub
|
|
321
|
+
npx frs new createPost --domain posts --method post --api v1
|
|
322
|
+
npx frs gen --root src/domains # refresh manifest (run before each build)
|
|
310
323
|
```
|
|
311
324
|
|
|
312
325
|
### Configure your APIs (`apis.ts`)
|
|
@@ -337,7 +350,7 @@ import { z } from "zod";
|
|
|
337
350
|
import { defineRoute } from "../../../../apis.js";
|
|
338
351
|
|
|
339
352
|
export default defineRoute({
|
|
340
|
-
api: "v1",
|
|
353
|
+
api: "v1", // typed: only registered tags accepted
|
|
341
354
|
method: "post",
|
|
342
355
|
input: z.object({ title: z.string() }),
|
|
343
356
|
output: z.object({ id: z.string() }),
|
|
@@ -365,16 +378,16 @@ export const { v1 } = apis.toFunctions(routes, onRequest, {
|
|
|
365
378
|
|
|
366
379
|
### Key features
|
|
367
380
|
|
|
368
|
-
| Feature
|
|
369
|
-
|
|
|
370
|
-
| **File-based routing** | `routes.ts` next to each useCase, scanned at build time
|
|
371
|
-
| **Multi-API registry** | One Cloud Function per tag, typed `api` field
|
|
372
|
-
| **Zod validation**
|
|
373
|
-
| **OpenAPI 3.1**
|
|
374
|
-
| **Interceptor**
|
|
375
|
-
| **Middlewares**
|
|
376
|
-
| **Typed context**
|
|
377
|
-
| **CLI**
|
|
381
|
+
| Feature | Details |
|
|
382
|
+
| ---------------------- | ------------------------------------------------------------------- |
|
|
383
|
+
| **File-based routing** | `routes.ts` next to each useCase, scanned at build time |
|
|
384
|
+
| **Multi-API registry** | One Cloud Function per tag, typed `api` field |
|
|
385
|
+
| **Zod validation** | Body / query / path params + optional response validation |
|
|
386
|
+
| **OpenAPI 3.1** | Auto-generated from Zod schemas; Scalar UI at `/docs` |
|
|
387
|
+
| **Interceptor** | Around-style hook for envelopes, error mapping, tracing |
|
|
388
|
+
| **Middlewares** | Per-API and per-route Hono middlewares |
|
|
389
|
+
| **Typed context** | Augment `ContextVariableMap` once, `c.get("user")` typed everywhere |
|
|
390
|
+
| **CLI** | `init` / `new` (interactive) / `gen` |
|
|
378
391
|
|
|
379
392
|
Full documentation: [frs.lpdjs.fr/guide/hono](https://frs.lpdjs.fr/guide/hono)
|
|
380
393
|
|
|
@@ -446,9 +459,18 @@ export const { functions } = servers.sync({
|
|
|
446
459
|
|
|
447
460
|
// Spread Cloud Functions into your exports
|
|
448
461
|
export const {
|
|
449
|
-
users_onCreate,
|
|
450
|
-
|
|
451
|
-
|
|
462
|
+
users_onCreate,
|
|
463
|
+
users_onUpdate,
|
|
464
|
+
users_onDelete,
|
|
465
|
+
sync_users,
|
|
466
|
+
posts_onCreate,
|
|
467
|
+
posts_onUpdate,
|
|
468
|
+
posts_onDelete,
|
|
469
|
+
sync_posts,
|
|
470
|
+
comments_onCreate,
|
|
471
|
+
comments_onUpdate,
|
|
472
|
+
comments_onDelete,
|
|
473
|
+
sync_comments,
|
|
452
474
|
adminsync,
|
|
453
475
|
} = functions;
|
|
454
476
|
```
|
|
@@ -488,7 +510,6 @@ Firestore emulator runs on `localhost:8080`, UI on `http://localhost:4000`.
|
|
|
488
510
|
|
|
489
511
|
MIT
|
|
490
512
|
|
|
491
|
-
|
|
492
513
|
## Installation
|
|
493
514
|
|
|
494
515
|
```bash
|
|
@@ -579,20 +600,23 @@ if (post) {
|
|
|
579
600
|
}
|
|
580
601
|
|
|
581
602
|
// Update
|
|
582
|
-
const updated = await repos.users.update("user123", {
|
|
603
|
+
const updated = await repos.users.update("user123", {
|
|
604
|
+
name: "New name",
|
|
605
|
+
age: 31,
|
|
606
|
+
});
|
|
583
607
|
```
|
|
584
608
|
|
|
585
609
|
## API reference
|
|
586
610
|
|
|
587
611
|
### `createRepositoryConfig()`
|
|
588
612
|
|
|
589
|
-
| Option | Description
|
|
590
|
-
| ------------- |
|
|
591
|
-
| `path` | Collection path in Firestore
|
|
592
|
-
| `isGroup` | `true` for collection group, `false` for simple
|
|
593
|
-
| `foreignKeys` | Keys for `get.by*` methods (single document lookup)
|
|
594
|
-
| `queryKeys` | Keys for `query.by*` methods (multi-document query)
|
|
595
|
-
| `refCb` | Function that returns the document reference
|
|
613
|
+
| Option | Description |
|
|
614
|
+
| ------------- | --------------------------------------------------- |
|
|
615
|
+
| `path` | Collection path in Firestore |
|
|
616
|
+
| `isGroup` | `true` for collection group, `false` for simple |
|
|
617
|
+
| `foreignKeys` | Keys for `get.by*` methods (single document lookup) |
|
|
618
|
+
| `queryKeys` | Keys for `query.by*` methods (multi-document query) |
|
|
619
|
+
| `refCb` | Function that returns the document reference |
|
|
596
620
|
|
|
597
621
|
**Sub-collection example:**
|
|
598
622
|
|
|
@@ -611,8 +635,8 @@ comments: createRepositoryConfig<CommentModel>()({
|
|
|
611
635
|
|
|
612
636
|
```typescript
|
|
613
637
|
interface QueryOptions<T> {
|
|
614
|
-
where?: [keyof T, WhereFilterOp, any][];
|
|
615
|
-
orWhere?: [keyof T, WhereFilterOp, any][][];
|
|
638
|
+
where?: [keyof T, WhereFilterOp, any][]; // AND conditions
|
|
639
|
+
orWhere?: [keyof T, WhereFilterOp, any][][]; // OR conditions
|
|
616
640
|
orderBy?: { field: keyof T; direction?: "asc" | "desc" }[];
|
|
617
641
|
limit?: number;
|
|
618
642
|
offset?: number;
|
|
@@ -703,7 +727,9 @@ const page = await repos.posts.query.paginate({
|
|
|
703
727
|
```typescript
|
|
704
728
|
import { count, sum, average } from "@lpdjs/firestore-repo-service";
|
|
705
729
|
|
|
706
|
-
const activeCount = await repos.users.aggregate.count({
|
|
730
|
+
const activeCount = await repos.users.aggregate.count({
|
|
731
|
+
where: [["isActive", "==", true]],
|
|
732
|
+
});
|
|
707
733
|
const totalViews = await repos.posts.aggregate.sum("views");
|
|
708
734
|
const avgAge = await repos.users.aggregate.average("age");
|
|
709
735
|
```
|
|
@@ -714,7 +740,9 @@ const avgAge = await repos.users.aggregate.average("age");
|
|
|
714
740
|
const result = await repos.users.transaction.run(async (txn) => {
|
|
715
741
|
const user = await txn.get(repos.users.documentRef("user123"));
|
|
716
742
|
if (user.exists()) {
|
|
717
|
-
txn.update(repos.users.documentRef("user123"), {
|
|
743
|
+
txn.update(repos.users.documentRef("user123"), {
|
|
744
|
+
age: user.data().age + 1,
|
|
745
|
+
});
|
|
718
746
|
}
|
|
719
747
|
return { success: true };
|
|
720
748
|
});
|
|
@@ -726,8 +754,14 @@ const result = await repos.users.transaction.run(async (txn) => {
|
|
|
726
754
|
// (status = 'active' AND age >= 18) OR (status = 'pending' AND verified = true)
|
|
727
755
|
const users = await repos.users.query.by({
|
|
728
756
|
orWhere: [
|
|
729
|
-
[
|
|
730
|
-
|
|
757
|
+
[
|
|
758
|
+
["status", "==", "active"],
|
|
759
|
+
["age", ">=", 18],
|
|
760
|
+
],
|
|
761
|
+
[
|
|
762
|
+
["status", "==", "pending"],
|
|
763
|
+
["verified", "==", true],
|
|
764
|
+
],
|
|
731
765
|
],
|
|
732
766
|
});
|
|
733
767
|
```
|
|
@@ -798,9 +832,18 @@ export const { functions } = servers.sync({
|
|
|
798
832
|
|
|
799
833
|
// Spread Cloud Functions into your exports
|
|
800
834
|
export const {
|
|
801
|
-
users_onCreate,
|
|
802
|
-
|
|
803
|
-
|
|
835
|
+
users_onCreate,
|
|
836
|
+
users_onUpdate,
|
|
837
|
+
users_onDelete,
|
|
838
|
+
sync_users,
|
|
839
|
+
posts_onCreate,
|
|
840
|
+
posts_onUpdate,
|
|
841
|
+
posts_onDelete,
|
|
842
|
+
sync_posts,
|
|
843
|
+
comments_onCreate,
|
|
844
|
+
comments_onUpdate,
|
|
845
|
+
comments_onDelete,
|
|
846
|
+
sync_comments,
|
|
804
847
|
adminsync,
|
|
805
848
|
} = functions;
|
|
806
849
|
```
|
|
@@ -837,4 +880,3 @@ Firestore emulator runs on `localhost:8080`, UI on `http://localhost:4000`.
|
|
|
837
880
|
## License
|
|
838
881
|
|
|
839
882
|
MIT
|
|
840
|
-
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
'use strict';var
|
|
2
|
+
'use strict';var fs=require('fs'),path=require('path'),process$1=require('process'),promises=require('readline/promises');var _={skipSegments:["useCases","useCase","use-cases","use-case"],casing:"preserve"};function L(e,s=_){let t=new Set(s.skipSegments.map(r=>r.toLowerCase()));return "/"+e.split("/").filter(Boolean).filter(r=>!t.has(r.toLowerCase())).map(r=>s.casing==="kebab"?Y(r):r).join("/")}function Y(e){return e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").replace(/[\s_]+/g,"-").toLowerCase()}function U(e,s,t){let o=N(e),r=N(s),n=0;for(;n<o.length&&n<r.length&&o[n]===r[n];)n++;let a=o.length-n,i=r.slice(n),u=(i[i.length-1]??"").replace(/\.[mc]?[tj]sx?$/i,""),d=t===""?u:`${u}${t}`;return i[i.length-1]=d,(a===0?"./":"../".repeat(a))+i.join("/")}function N(e){return e.replace(/\\/g,"/").replace(/\/+$/,"").split("/").filter((t,o)=>!(o===0&&t===""))}var ee="/**\n * AUTO-GENERATED by `@lpdjs/firestore-repo-service` Hono codegen.\n * Do not edit by hand \u2014 re-run `hono:gen` after adding / removing route files.\n */\n";function B(e,s){let t=path.dirname(s.outFile);fs.mkdirSync(t,{recursive:true});let o=s.banner??ee,r=(s.now??new Date).toISOString(),n=s.importExtension,a=[],i=[],c=[];e.forEach((d,f)=>{let h=U(t,d.absPath,n),v=L(d.relDir,s.derive);a.push(`import mod${f} from ${JSON.stringify(h)};`),i.push(` { __derivedPath: ${JSON.stringify(v)}, mod: mod${f} },`),c.push({source:d.relPath,url:v});});let u=`${o}// Generated at ${r} \u2014 ${e.length} route file${e.length===1?"":"s"}.
|
|
3
3
|
|
|
4
4
|
import type { AnyRouteDef, RouteModuleDefault } from "@lpdjs/firestore-repo-service/servers/hono";
|
|
5
5
|
|
|
@@ -8,25 +8,29 @@ import type { AnyRouteDef, RouteModuleDefault } from "@lpdjs/firestore-repo-serv
|
|
|
8
8
|
|
|
9
9
|
`:`
|
|
10
10
|
`)+`const __defs: { __derivedPath: string; mod: RouteModuleDefault }[] = [
|
|
11
|
-
`+
|
|
12
|
-
`)+(
|
|
11
|
+
`+i.join(`
|
|
12
|
+
`)+(i.length?`
|
|
13
13
|
`:"")+`];
|
|
14
14
|
|
|
15
15
|
export const routes: AnyRouteDef[] = __defs.flatMap(({ __derivedPath, mod }) => {
|
|
16
16
|
const list = Array.isArray(mod) ? mod : [mod];
|
|
17
17
|
return list.map((route) => ({ ...route, path: route.path ?? __derivedPath }));
|
|
18
18
|
});
|
|
19
|
-
`;return fs.writeFileSync(s.outFile,
|
|
19
|
+
`;return fs.writeFileSync(s.outFile,u,"utf8"),{outFile:s.outFile,routeCount:e.length,derivedPaths:c}}var A={routesFile:"routes.ts",excludeSegments:["node_modules","__generated__","tests","__tests__",".turbo","dist","build",".next"]};function G(e,s=A){let t=[];return z(e,e,s,t),t.sort((o,r)=>o.relPath.localeCompare(r.relPath)),t}function z(e,s,t,o){let r;try{r=fs.readdirSync(s);}catch{return}for(let n of r){if(t.excludeSegments.includes(n))continue;let a=path.join(s,n),i;try{i=fs.statSync(a);}catch{continue}if(i.isDirectory())z(e,a,t,o);else if(i.isFile()&&n===t.routesFile){let c=path.relative(e,a).split(path.sep).join("/"),u=c.replace(/\/?[^/]+$/,"");o.push({absPath:a,relPath:c,relDir:u});}}}function pe(e){let[s,...t]=e,o={};for(let r=0;r<t.length;r++){let n=t[r];if(!n.startsWith("--"))continue;let a=n.slice(2),i=t[r+1];i&&!i.startsWith("--")?(o[a]=i,r++):o[a]=true;}return {command:s??"help",flags:o}}function H(){console.log(`frs \u2014 Hono file-based codegen
|
|
20
20
|
|
|
21
21
|
Usage:
|
|
22
|
-
frs
|
|
23
|
-
frs
|
|
24
|
-
frs
|
|
25
|
-
frs
|
|
22
|
+
frs init [flags]
|
|
23
|
+
frs gen [flags]
|
|
24
|
+
frs new <name> [flags]
|
|
25
|
+
frs add service <name> [flags]
|
|
26
|
+
frs help
|
|
26
27
|
|
|
27
28
|
Flags (init):
|
|
28
29
|
--root <dir> Domain root to create (default: src/domains)
|
|
29
30
|
--apis-file <path> Path to the apis.ts file to create (default: src/apis.ts)
|
|
31
|
+
--services-file <path>
|
|
32
|
+
Path to the services.ts file to create
|
|
33
|
+
(default: src/services.ts)
|
|
30
34
|
--apis <list> Comma-separated API tags to register (default: v1)
|
|
31
35
|
--base-path <prefix> basePath shared by all APIs (default: derived from tag)
|
|
32
36
|
--force Overwrite existing files
|
|
@@ -61,122 +65,189 @@ Flags (new <name>):
|
|
|
61
65
|
--force Overwrite if files already exist
|
|
62
66
|
--yes Skip prompts, use defaults / flag values
|
|
63
67
|
|
|
68
|
+
Flags (add service <name>):
|
|
69
|
+
--services-file <path>
|
|
70
|
+
Path to the services.ts file (default: src/services.ts)
|
|
71
|
+
--services-dir <dir> Directory hosting individual service files
|
|
72
|
+
(default: <dir-of-services-file>/services)
|
|
73
|
+
--force Overwrite existing files
|
|
74
|
+
|
|
64
75
|
Examples:
|
|
65
|
-
frs
|
|
66
|
-
frs
|
|
67
|
-
frs
|
|
68
|
-
|
|
69
|
-
|
|
76
|
+
frs init
|
|
77
|
+
frs new createPost --domain posts --method post
|
|
78
|
+
frs new listPosts --domain posts --method get --api v1
|
|
79
|
+
frs add service postRepo
|
|
80
|
+
`);}function W(e){if(typeof e=="string")return e.split(",").map(s=>s.trim()).filter(Boolean)}function l(e){return typeof e=="string"?e:void 0}function K(e){if(e||!process$1.stdin.isTTY)return {ask:async(t,o)=>o??"",askChoice:async(t,o,r)=>r??"",askBool:async(t,o)=>o,close:()=>{}};let s=promises.createInterface({input:process$1.stdin,output:process$1.stdout});return {async ask(t,o){let r=o?` (${o})`:"";return (await s.question(`? ${t}${r} \u203A `)).trim()||o||""},async askChoice(t,o,r){let n=` [${o.join("/")}${r?`, default: ${r}`:""}]`;for(;;){let a=(await s.question(`? ${t}${n} \u203A `)).trim().toLowerCase();if(!a&&r)return r;if(o.includes(a))return a;console.log(` invalid choice \u2014 pick one of: ${o.join(", ")}`);}},async askBool(t,o){let r=` (${o?"Y/n":"y/N"})`,n=(await s.question(`? ${t}${r} \u203A `)).trim().toLowerCase();return n?n==="y"||n==="yes"||n==="true":o},close:()=>s.close()}}async function ue(e){let s=l(e.root);s||(console.error("[frs] --root is required"),process.exit(2));let t=path.resolve(process.cwd(),s);fs.existsSync(t)||(console.error(`[frs] root not found: ${t}`),process.exit(2));let o=l(e.out)??"__generated__/routes.ts",r=W(e.skip)??_.skipSegments,n=l(e.casing)==="kebab"?"kebab":_.casing,a={skipSegments:r,casing:n},i=l(e.ext)??".js",c=W(e.exclude)??A.excludeSegments,u=l(e["routes-file"])??A.routesFile,f=G(t,{routesFile:u,excludeSegments:c});f.length===0&&console.warn(`[frs] no "${u}" files found under ${t} \u2014 generated an empty manifest.`);let h=B(f,{outFile:path.resolve(t,o),derive:a,importExtension:i});if(!e.silent){console.log(`[frs] wrote ${h.outFile} (${h.routeCount} route${h.routeCount===1?"":"s"})`);for(let{source:v,url:y}of h.derivedPaths)console.log(` ${y.padEnd(48)} \u2190 ${v}`);}}async function le(e,s){let t=s.yes===true,o=K(t);try{let r=e&&!e.startsWith("--")?e:void 0;r||(r=(await o.ask("Route name (e.g. createPost)")).trim(),r||(console.error("[frs] route name is required"),process.exit(2)));let n=l(s.domain);n||(n=(await o.ask("Domain name (e.g. posts)")).trim(),n||(console.error("[frs] --domain is required"),process.exit(2)));let a=l(s.root)??"src/domains",i=l(s.method)?.toLowerCase();i||(i=await o.askChoice("HTTP method",["get","post","put","patch","delete"],"post")),["get","post","put","patch","delete"].includes(i)||(console.error(`[frs] invalid --method: ${i}`),process.exit(2));let c=l(s.api);c||(c=(await o.ask("API tag","v1")).trim()||"v1");let u=l(s["usecase-folder"])??"useCases",d=s["with-usecase"]===void 0?t?!0:await o.askBool("Scaffold useCase.ts?",!0):s["with-usecase"]!==!1,f=s["with-test"]===void 0?t||!d?d:await o.askBool("Scaffold useCase.test.ts (Vitest)?",!0):s["with-test"]!==!1,h=s.force===!0,v=path.resolve(process.cwd(),a),y=path.resolve(v,n,u,r),$=path.resolve(y,"routes.ts"),b=path.resolve(y,"useCase.ts"),x=path.resolve(y,"useCase.test.ts");fs.mkdirSync(y,{recursive:!0});let m=`${r.charAt(0).toUpperCase()}${r.slice(1)}UseCase`,k=`/**
|
|
81
|
+
* ${m} \u2014 pure business logic, no HTTP awareness.
|
|
70
82
|
* Reusable across multiple routes / cron jobs / triggers.
|
|
71
83
|
*/
|
|
72
84
|
|
|
73
|
-
export interface ${
|
|
85
|
+
export interface ${m}Input {
|
|
74
86
|
// TODO: define the input shape
|
|
75
87
|
example: string;
|
|
76
88
|
}
|
|
77
89
|
|
|
78
|
-
export interface ${
|
|
90
|
+
export interface ${m}Output {
|
|
79
91
|
// TODO: define the output shape
|
|
80
92
|
id: string;
|
|
81
93
|
}
|
|
82
94
|
|
|
83
|
-
export class ${
|
|
95
|
+
export class ${m} {
|
|
84
96
|
// TODO: inject repositories / services via the constructor.
|
|
85
97
|
// constructor(private readonly repo: SomeRepository) {}
|
|
86
98
|
|
|
87
|
-
async execute(input: ${
|
|
99
|
+
async execute(input: ${m}Input): Promise<${m}Output> {
|
|
88
100
|
// TODO: implement
|
|
89
101
|
return { id: input.example };
|
|
90
102
|
}
|
|
91
103
|
}
|
|
92
|
-
`,
|
|
104
|
+
`,D=i==="get"?`z.object({
|
|
93
105
|
// GET \u2192 lu depuis les query params
|
|
94
106
|
example: z.string(),
|
|
95
107
|
})`:`z.object({
|
|
96
|
-
// ${
|
|
108
|
+
// ${i.toUpperCase()} \u2192 lu depuis le body JSON
|
|
97
109
|
example: z.string(),
|
|
98
|
-
})`,
|
|
110
|
+
})`,S=d?` const useCase = new ${m}();
|
|
99
111
|
const data = await useCase.execute(input);
|
|
100
112
|
return data;`:` // TODO: business logic
|
|
101
|
-
return { id: input.example };`,
|
|
102
|
-
`:"",
|
|
103
|
-
import { defineRoute } from "${
|
|
104
|
-
${
|
|
113
|
+
return { id: input.example };`,j=d?`import { ${m} } from "./useCase.js";
|
|
114
|
+
`:"",I=`import { z } from "zod";
|
|
115
|
+
import { defineRoute } from "${l(s["apis-import"])??me(v,y)}";
|
|
116
|
+
${j}
|
|
105
117
|
export default defineRoute({
|
|
106
|
-
api: "${
|
|
107
|
-
method: "${
|
|
118
|
+
api: "${c}",
|
|
119
|
+
method: "${i}",
|
|
108
120
|
|
|
109
|
-
input: ${
|
|
121
|
+
input: ${D},
|
|
110
122
|
|
|
111
123
|
output: z.object({
|
|
112
124
|
id: z.string(),
|
|
113
125
|
}),
|
|
114
126
|
|
|
115
|
-
summary: "TODO: ${
|
|
116
|
-
tags: ["${
|
|
127
|
+
summary: "TODO: ${r}",
|
|
128
|
+
tags: ["${n}"],
|
|
117
129
|
|
|
118
130
|
handler: async ({ input }) => {
|
|
119
|
-
${
|
|
131
|
+
${S}
|
|
120
132
|
},
|
|
121
133
|
});
|
|
122
|
-
`,
|
|
123
|
-
import { ${
|
|
134
|
+
`,p=[],C=[],E=(w,V)=>{if(fs.existsSync(w)&&!h){C.push(w);return}fs.writeFileSync(w,V,"utf8"),p.push(w);};if(E($,I),d&&E(b,k),d&&f){let w=`import { describe, it, expect } from "vitest";
|
|
135
|
+
import { ${m} } from "./useCase.js";
|
|
124
136
|
|
|
125
|
-
describe("${
|
|
137
|
+
describe("${m}", () => {
|
|
126
138
|
it("returns a response shaped like the output schema", async () => {
|
|
127
|
-
const useCase = new ${
|
|
139
|
+
const useCase = new ${m}();
|
|
128
140
|
const result = await useCase.execute({ example: "hello" });
|
|
129
141
|
expect(result).toMatchObject({ id: expect.any(String) });
|
|
130
142
|
});
|
|
131
143
|
|
|
132
144
|
// TODO: add error-path tests, repository mocks, etc.
|
|
133
145
|
});
|
|
134
|
-
`;
|
|
135
|
-
[frs
|
|
146
|
+
`;E(x,w);}for(let w of p)console.log(`[frs] wrote ${w}`);for(let w of C)console.log(`[frs] skipped ${w} (use --force to overwrite)`);console.log(`
|
|
147
|
+
[frs] reminder: run "frs gen --root ${a}" to refresh the manifest.`);}finally{o.close();}}async function de(e){let s=e.yes===true,t=K(s);try{let o=e.force===!0,r=l(e.root);r||(r=(await t.ask("Domain root","src/domains")).trim()||"src/domains");let n=l(e["apis-file"]);n||(n=(await t.ask("apis.ts location","src/apis.ts")).trim()||"src/apis.ts");let a=l(e["services-file"]);if(!a){let p=n.replace(/apis\.ts$/,"services.ts")||"src/services.ts";a=(await t.ask("services.ts location",p)).trim()||p;}let i=l(e.apis);i||(i=(await t.ask("API tags (comma-separated)","v1")).trim()||"v1");let c=i.split(",").map(p=>p.trim()).filter(Boolean);c.length===0&&(console.error("[frs] at least one API tag is required"),process.exit(2));let u=l(e["base-path"]),d=path.resolve(process.cwd(),r),f=path.resolve(process.cwd(),n),h=path.resolve(process.cwd(),a),v=path.resolve(d,"__generated__"),y=path.resolve(v,"routes.ts"),$=[],b=[],x=(p,C)=>{if(fs.mkdirSync(path.dirname(p),{recursive:!0}),fs.existsSync(p)&&!o){b.push(p);return}fs.writeFileSync(p,C,"utf8"),$.push(p);},m=c.map(p=>{let C=u??`/${p}`;return ` ${p}: {
|
|
148
|
+
basePath: "${C}",
|
|
149
|
+
openapi: {
|
|
150
|
+
info: { title: "${p.toUpperCase()} API", version: "1.0.0", description: "" },
|
|
151
|
+
},
|
|
152
|
+
verbose: process.env["NODE_ENV"] !== "production",
|
|
153
|
+
},`}).join(`
|
|
154
|
+
`),k=`import { createApiRegistry } from "@lpdjs/firestore-repo-service/servers/hono";
|
|
155
|
+
import { services } from "${O(path.dirname(f),h)}";
|
|
136
156
|
|
|
137
157
|
/**
|
|
138
158
|
* Single source of truth for every API exposed by this project.
|
|
139
159
|
* Add per-API middlewares, interceptors, OpenAPI metadata here.
|
|
160
|
+
*
|
|
161
|
+
* The shared \`services\` container is injected into every HonoServer the
|
|
162
|
+
* registry builds \u2014 handlers / interceptors receive it via \`{ services }\`
|
|
163
|
+
* and the built-in \`services.ctx.c\` resolves to the current request.
|
|
140
164
|
*/
|
|
141
|
-
export const apis = createApiRegistry(
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
verbose: process.env["NODE_ENV"] !== "production",
|
|
148
|
-
},`}).join(`
|
|
149
|
-
`)}
|
|
150
|
-
});
|
|
165
|
+
export const apis = createApiRegistry(
|
|
166
|
+
{
|
|
167
|
+
${m}
|
|
168
|
+
},
|
|
169
|
+
{ services },
|
|
170
|
+
);
|
|
151
171
|
|
|
152
172
|
/** Typed helper used inside every route file. */
|
|
153
173
|
export const defineRoute = apis.defineRoute;
|
|
154
|
-
`;
|
|
155
|
-
|
|
174
|
+
`;x(f,k),x(h,`import { createServices } from "@lpdjs/firestore-repo-service/servers/hono";
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Global DI container \u2014 declare every singleton (repositories, SDK
|
|
178
|
+
* clients, loggers, useCases) here. Each factory is invoked once on first
|
|
179
|
+
* access and the instance is cached for the process lifetime.
|
|
180
|
+
*
|
|
181
|
+
* Factories receive a typed proxy of every other service plus the
|
|
182
|
+
* built-in \`ctx\` (current request \`Context\` via AsyncLocalStorage).
|
|
183
|
+
* Destructure what you need \u2014 TypeScript will infer everything.
|
|
184
|
+
*
|
|
185
|
+
* @example
|
|
186
|
+
* \`\`\`ts
|
|
187
|
+
* postRepo: () => new PostRepo(),
|
|
188
|
+
* createPostUseCase: ({ ctx, postRepo }) =>
|
|
189
|
+
* new CreatePostUseCase(ctx, postRepo),
|
|
190
|
+
* \`\`\`
|
|
191
|
+
*/
|
|
192
|
+
export const services = createServices({
|
|
193
|
+
// TODO: declare your services here.
|
|
194
|
+
// Example:
|
|
195
|
+
// db: () => getFirestore(),
|
|
196
|
+
// postRepo: ({ db }) => new PostRepo(db),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
/** Convenience type \u2014 \`function fn(svc: Services) { ... }\`. */
|
|
200
|
+
export type Services = typeof services;
|
|
201
|
+
`);let S=`// AUTO-GENERATED by frs \u2014 do not edit.
|
|
202
|
+
// Run \`frs gen --root ${r}\` to refresh.
|
|
156
203
|
|
|
157
204
|
import type { AnyRouteDef } from "@lpdjs/firestore-repo-service/servers/hono";
|
|
158
205
|
|
|
159
206
|
export const routes: AnyRouteDef[] = [];
|
|
160
|
-
`;
|
|
207
|
+
`;x(y,S);let j=O(path.dirname(f),f),q=O(path.dirname(f),y),I=c.length===1?`export const { ${c[0]} } = apis.toFunctions(routes, onRequest, {`:`export const { ${c.join(", ")} } = apis.toFunctions(routes, onRequest, {`;for(let p of $)console.log(`[frs] wrote ${p}`);for(let p of b)console.log(`[frs] skipped ${p} (use --force to overwrite)`);console.log(`
|
|
161
208
|
Next steps:
|
|
162
209
|
|
|
163
210
|
1. Wire the registry in your Functions entrypoint (e.g. src/index.ts):
|
|
164
211
|
|
|
165
212
|
import { onRequest } from "firebase-functions/v2/https";
|
|
166
|
-
import { apis } from "${
|
|
167
|
-
import { routes } from "${
|
|
213
|
+
import { apis } from "${j}";
|
|
214
|
+
import { routes } from "${q}";
|
|
168
215
|
|
|
169
|
-
${
|
|
216
|
+
${I}
|
|
170
217
|
defaults: { region: "us-central1", invoker: "public" },
|
|
171
218
|
});
|
|
172
219
|
|
|
173
220
|
2. Scaffold a first route:
|
|
174
221
|
|
|
175
|
-
frs
|
|
222
|
+
frs new createPost --domain posts --method post --api ${c[0]}
|
|
176
223
|
|
|
177
224
|
3. Refresh the manifest before each build:
|
|
178
225
|
|
|
179
|
-
frs
|
|
180
|
-
`);}finally{t.close();}}function
|
|
181
|
-
|
|
226
|
+
frs gen --root ${r}
|
|
227
|
+
`);}finally{t.close();}}function O(e,s){let t=path.relative(e,s).replace(/\\/g,"/");return t=t.replace(/\.ts$/,".js"),t.startsWith(".")||(t=`./${t}`),t}async function fe(e,s,t){e!=="service"&&(console.error(`[frs] unknown "add" target: ${e??"(missing)"} \u2014 supported: service`),process.exit(2)),s||(console.error("[frs] service name is required: frs add service <name>"),process.exit(2));let o=t.force===true,r=l(t["services-file"])??"src/services.ts",n=path.resolve(process.cwd(),r),a=l(t["services-dir"])??path.resolve(path.dirname(n),"services"),i=path.resolve(process.cwd(),a);fs.existsSync(n)||(console.error(`[frs] services file not found: ${n}
|
|
228
|
+
Run \`frs init\` first or pass --services-file <path>.`),process.exit(2)),fs.mkdirSync(i,{recursive:true});let c=`${s.charAt(0).toUpperCase()}${s.slice(1)}Service`,u=path.resolve(i,`${s}.ts`),d=`/**
|
|
229
|
+
* ${c} \u2014 generated by \`frs add service ${s}\`.
|
|
230
|
+
*
|
|
231
|
+
* Singleton instantiated lazily on first access. Async resources (DB
|
|
232
|
+
* connections, SDK clients) should be lazy-loaded inside the class:
|
|
233
|
+
*
|
|
234
|
+
* @example
|
|
235
|
+
* \`\`\`ts
|
|
236
|
+
* private _client: SomeClient | undefined;
|
|
237
|
+
* get client(): SomeClient {
|
|
238
|
+
* return (this._client ??= new SomeClient({...}));
|
|
239
|
+
* }
|
|
240
|
+
* \`\`\`
|
|
241
|
+
*/
|
|
242
|
+
export class ${c} {
|
|
243
|
+
// TODO: add fields, methods, dependencies.
|
|
244
|
+
hello(): string {
|
|
245
|
+
return "hello from ${s}";
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
`;fs.existsSync(u)&&!o?console.log(`[frs] skipped ${u} (use --force to overwrite)`):(fs.writeFileSync(u,d,"utf8"),console.log(`[frs] wrote ${u}`));let f=fs.readFileSync(n,"utf8"),h=O(path.dirname(n),u),v=`import { ${c} } from "${h}";`,y=` ${s}: () => new ${c}(),`;if(f.includes(v)){console.log(`[frs] services.ts already registers "${s}" \u2014 skipping.`);return}let $=f.split(`
|
|
249
|
+
`),b=-1;for(let S=0;S<$.length;S++)/^import\s/.test($[S])&&(b=S);b>=0?$.splice(b+1,0,v):$.unshift(v);let x=$.join(`
|
|
250
|
+
`),m=x.match(/createServices\s*\(\s*\{/);if(!m){console.error(`[frs] could not find \`createServices({\` in ${n} \u2014 register "${s}" manually.`);return}let k=m.index+m[0].length,D=x.slice(0,k)+`
|
|
251
|
+
`+y+x.slice(k);fs.writeFileSync(n,D,"utf8"),console.log(`[frs] updated ${n} (+ ${s})`);}function me(e,s){let t=["apis.ts","apis.js","api.ts","api.js"],o=[e,path.dirname(e),path.dirname(path.dirname(e))];for(let r of o)for(let n of t){let a=path.resolve(r,n);if(fs.existsSync(a)){let i=path.relative(s,a).replace(/\\/g,"/");return i=i.replace(/\.ts$/,".js").replace(/\.js$/,".js"),i.startsWith(".")||(i=`./${i}`),i}}return "../../../../apis.js"}async function ge(){let e=process.argv.slice(2),{command:s,flags:t}=pe(e);switch(s){case "init":await de(t);return;case "gen":await ue(t);return;case "new":await le(e[1],t);return;case "add":await fe(e[1],e[2],t);return;case "help":case "--help":case "-h":H();return;default:console.error(`[frs] unknown command: ${s}
|
|
252
|
+
`),H(),process.exit(2);}}ge().catch(e=>{console.error(e),process.exit(1);});//# sourceMappingURL=cli.cjs.map
|
|
182
253
|
//# sourceMappingURL=cli.cjs.map
|