@stjoseph/prumo 0.0.6 → 0.1.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/.prumo-templates/api/auth.md +51 -95
- package/.prumo-templates/api/config.md +30 -56
- package/.prumo-templates/api/errors.md +64 -44
- package/.prumo-templates/api/logging.md +27 -44
- package/.prumo-templates/api/observability.md +36 -62
- package/.prumo-templates/api/pagination.md +23 -38
- package/.prumo-templates/api/routes.md +67 -48
- package/.prumo-templates/api/structure.md +130 -0
- package/.prumo-templates/client/data.md +4 -3
- package/.prumo-templates/core/conventions.md +10 -7
- package/.prumo-templates/core/testing.md +43 -26
- package/.prumo-templates/core/tooling.md +33 -30
- package/.prumo-templates/database/entities.md +53 -61
- package/.prumo-templates/database/queries.md +34 -47
- package/.prumo-templates/database/transactions.md +28 -38
- package/.prumo-templates/mcp/server.md +54 -0
- package/.prumo-templates/monorepo/dependencies.md +3 -3
- package/.prumo-templates/monorepo/layout.md +1 -1
- package/README.md +6 -6
- package/dist/asker.js +4 -0
- package/dist/cli.js +4 -0
- package/dist/commands.js +2 -0
- package/dist/compose.js +6 -2
- package/dist/context.js +2 -0
- package/dist/generate.js +8 -0
- package/dist/mcp.js +100 -0
- package/dist/questions.js +21 -1
- package/package.json +4 -3
- package/templates/api/.swcrc +13 -0
- package/templates/api/biome.jsonc +5 -3
- package/templates/api/package.json +21 -27
- package/templates/api/pnpm-workspace.yaml +0 -2
- package/templates/api/src/domain/shared/errors/domain-error.ts +5 -0
- package/templates/api/src/domain/shared/transactions/transaction-manager.ts +5 -0
- package/templates/api/src/domain/users/dto/profile-response.dto.ts +14 -0
- package/templates/api/src/domain/users/dto/update-profile.dto.ts +10 -0
- package/templates/api/src/domain/users/entities/profile.entity.ts +26 -0
- package/templates/api/src/domain/users/errors/profile-not-found.error.ts +9 -0
- package/templates/api/src/domain/users/repositories/profile.repository.ts +8 -0
- package/templates/api/src/domain/users/use-cases/create-profile.use-case.ts +19 -0
- package/templates/api/src/domain/users/use-cases/find-profile.use-case.ts +22 -0
- package/templates/api/src/domain/users/use-cases/update-profile.use-case.ts +26 -0
- package/templates/api/src/infra/auth/auth.cli.ts +5 -0
- package/templates/api/src/{auth → infra/auth}/auth.factory.ts +26 -1
- package/templates/api/src/infra/config/env.ts +47 -0
- package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +16 -0
- package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +28 -0
- package/templates/api/src/{mikro-orm.config.ts → infra/database/mikroorm/mikro-orm.config.ts} +1 -1
- package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +17 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-profile.repository.ts +17 -0
- package/templates/api/src/infra/database/mikroorm/transactions/mikroorm-transaction-manager.ts +14 -0
- package/templates/api/src/infra/di/index.ts +32 -0
- package/templates/api/src/infra/di/users.di.ts +7 -0
- package/templates/api/src/infra/http/app.ts +99 -0
- package/templates/api/src/infra/http/controllers/auth.controller.ts +36 -0
- package/templates/api/src/infra/http/controllers/health.controller.ts +19 -0
- package/templates/api/src/infra/http/controllers/mcp.controller.ts +40 -0
- package/templates/api/src/infra/http/controllers/users.controller.ts +35 -0
- package/templates/api/src/infra/http/errors/error-handler.ts +116 -0
- package/templates/api/src/infra/http/errors/http-error.ts +8 -0
- package/templates/api/src/infra/http/fastify.d.ts +17 -0
- package/templates/api/src/infra/http/fetch-bridge.ts +42 -0
- package/templates/api/src/infra/http/hooks/auth.hook.ts +32 -0
- package/templates/api/src/infra/http/hooks/request-context.hook.ts +9 -0
- package/templates/api/src/infra/http/server.ts +34 -0
- package/templates/api/src/infra/mcp/mcp.server.ts +20 -0
- package/templates/api/src/infra/mcp/tools/users.tool.ts +39 -0
- package/templates/api/test/domain/users/use-cases/find-profile.use-case.spec.ts +22 -0
- package/templates/api/test/domain/users/use-cases/update-profile.use-case.spec.ts +26 -0
- package/templates/api/{src → test/infra}/auth/auth.factory.spec.ts +4 -11
- package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +8 -0
- package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-profile.repository.spec.ts +51 -0
- package/templates/api/test/infra/database/mikroorm/transactions/mikroorm-transaction-manager.spec.ts +29 -0
- package/templates/api/test/infra/http/app.spec.ts +21 -0
- package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +152 -0
- package/templates/api/test/infra/http/controllers/users.controller.spec.ts +105 -0
- package/templates/api/test/support/factories/profile.factory.ts +25 -0
- package/templates/api/test/{factories → support/factories}/user.factory.ts +1 -1
- package/templates/api/test/support/fakes/in-memory-profile.repository.ts +14 -0
- package/templates/api/test/{setup.ts → support/setup.ts} +26 -14
- package/templates/api/tsconfig.json +3 -6
- package/templates/api/vitest.config.mts +4 -4
- package/templates/mobile/jest.config.js +1 -1
- package/templates/mobile/package.json +2 -2
- package/templates/mobile/{src → test}/features/auth/authenticated-layout.spec.tsx +1 -1
- package/templates/mobile/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
- package/templates/mobile/{src → test}/features/auth/sign-out-button.spec.tsx +2 -2
- package/templates/mobile/{src → test}/features/profile/profile-form.spec.tsx +2 -2
- package/templates/mobile/tsconfig.json +2 -1
- package/templates/site/{src → test}/app/page.spec.ts +1 -1
- package/templates/site/tsconfig.json +2 -1
- package/templates/site/vitest.config.mts +4 -2
- package/templates/web/package.json +4 -2
- package/templates/web/src/features/auth/auth-client.ts +5 -0
- package/templates/web/src/features/auth/sign-in-form.tsx +10 -3
- package/templates/web/src/features/oauth/consent-form.tsx +63 -0
- package/templates/web/src/routeTree.gen.ts +21 -2
- package/templates/web/src/routes/_authenticated/consent.tsx +21 -0
- package/templates/web/{src → test}/app.spec.tsx +2 -2
- package/templates/web/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
- package/templates/web/test/features/oauth/consent-form.spec.tsx +45 -0
- package/templates/web/{src → test}/features/profile/profile-form.spec.tsx +2 -2
- package/templates/web/test/{render-app.tsx → support/render-app.tsx} +1 -1
- package/templates/web/tsconfig.json +2 -1
- package/templates/web/vite.config.ts +3 -3
- package/.prumo-templates/api/modules.md +0 -117
- package/templates/api/nest-cli.json +0 -10
- package/templates/api/src/app.module.ts +0 -46
- package/templates/api/src/auth/auth.constants.ts +0 -2
- package/templates/api/src/auth/auth.controller.ts +0 -19
- package/templates/api/src/auth/auth.guard.ts +0 -43
- package/templates/api/src/auth/auth.module.ts +0 -30
- package/templates/api/src/auth/auth.ts +0 -4
- package/templates/api/src/auth/current-user.decorator.ts +0 -9
- package/templates/api/src/auth/express.d.ts +0 -10
- package/templates/api/src/auth/public.decorator.ts +0 -4
- package/templates/api/src/config/env.module.ts +0 -11
- package/templates/api/src/config/env.ts +0 -81
- package/templates/api/src/errors/problem-details.filter.ts +0 -86
- package/templates/api/src/errors/validation-exception.factory.ts +0 -30
- package/templates/api/src/health/health.controller.ts +0 -32
- package/templates/api/src/health/health.module.ts +0 -9
- package/templates/api/src/main.ts +0 -43
- package/templates/api/src/mikro-orm.factory.ts +0 -14
- package/templates/api/src/request-context/request-context.module.ts +0 -9
- package/templates/api/src/request-context/request-context.service.ts +0 -17
- package/templates/api/src/request-context/request-id.middleware.ts +0 -19
- package/templates/api/src/users/dto/update-profile.dto.ts +0 -20
- package/templates/api/src/users/entities/profile.entity.ts +0 -27
- package/templates/api/src/users/users.controller.ts +0 -23
- package/templates/api/src/users/users.module.ts +0 -10
- package/templates/api/src/users/users.service.spec.ts +0 -44
- package/templates/api/src/users/users.service.ts +0 -36
- package/templates/api/test/factories/profile.factory.ts +0 -26
- package/templates/api/tsconfig.build.json +0 -8
- /package/templates/api/test/{global-setup.ts → support/global-setup.ts} +0 -0
- /package/templates/mobile/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
- /package/templates/web/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
- /package/templates/web/test/{setup.ts → support/setup.ts} +0 -0
|
@@ -2,90 +2,64 @@
|
|
|
2
2
|
|
|
3
3
|
## Rule
|
|
4
4
|
|
|
5
|
-
Expose two health routes
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
Expose two public health routes in `src/infra/http/controllers/health.controller.ts`. `/api/health/live`
|
|
6
|
+
checks nothing. `/api/health/ready` checks the database with `orm.checkConnection()` and answers 503 when it
|
|
7
|
+
is down.
|
|
8
8
|
|
|
9
|
-
Connect the ORM explicitly
|
|
9
|
+
Connect the ORM explicitly in `server.ts`, before the application listens.
|
|
10
10
|
|
|
11
|
-
Do not install an error-reporting or tracing service by default. A project has structured logs and
|
|
12
|
-
|
|
11
|
+
Do not install an error-reporting or tracing service by default. A project has structured logs and these two
|
|
12
|
+
routes, and nothing else, until its owner chooses otherwise.
|
|
13
13
|
|
|
14
|
-
When Sentry is chosen, wire it exactly as follows
|
|
14
|
+
When Sentry is chosen, wire it exactly as follows:
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
off. Never attach a request body.
|
|
16
|
+
- Install `@sentry/node`. Call `Sentry.init` in `src/infra/observability/instrument.ts`, with
|
|
17
|
+
`Sentry.fastifyIntegration()` in `integrations`, and import that file as the first line of `server.ts`,
|
|
18
|
+
before `reflect-metadata`.
|
|
19
|
+
- Give `fastifyIntegration` a `shouldHandleError` that uses the status the error handler resolves, so only a
|
|
20
|
+
5xx becomes an event. Export that mapping from `error-handler.ts` rather than writing a second one.
|
|
21
|
+
- Do not call `setupFastifyErrorHandler`: in `@sentry/node` 11 it does nothing.
|
|
22
|
+
- Attach the request id as a tag, and the user's id when a session exists. Leave PII sending off. Never
|
|
23
|
+
attach a request body.
|
|
25
24
|
|
|
26
25
|
## Rationale
|
|
27
26
|
|
|
28
|
-
Liveness and readiness answer opposite questions
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
Restarting does not fix a database, and the wobble becomes an outage.
|
|
27
|
+
Liveness and readiness answer opposite questions, *should I restart this process* and *should I send it
|
|
28
|
+
traffic*, and one endpoint forces one answer to both. The routine failure: the endpoint checks the database,
|
|
29
|
+
the database wobbles, and the orchestrator restarts a healthy process.
|
|
32
30
|
|
|
33
|
-
**MikroORM 7's `init` no longer connects
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
an unreachable database fails the boot instead of a request.
|
|
31
|
+
**MikroORM 7's `init` no longer connects.** Without the explicit connect, readiness reports the database down
|
|
32
|
+
until a first query, and no query reaches an instance that is not ready. Connecting first also means an
|
|
33
|
+
unreachable database fails the boot instead of a request.
|
|
37
34
|
|
|
38
|
-
Error reporting is not installed because it requires a commercial account
|
|
39
|
-
|
|
40
|
-
inside a project that is theirs. Free tooling can simply be deleted; a SaaS dependency is different in
|
|
41
|
-
kind. So the wiring is written down and ready, and absent until wanted.
|
|
35
|
+
Error reporting is not installed because it requires a commercial account: an account, a DSN and eventually a
|
|
36
|
+
bill is a commercial decision made on the owner's behalf. The wiring is written down and absent until wanted.
|
|
42
37
|
|
|
43
38
|
`Sentry.init` must run before other modules load, because the instrumentation patches them as they are
|
|
44
|
-
imported.
|
|
45
|
-
|
|
46
|
-
The 5xx boundary is the status, not the exception type: a deliberately thrown `ServiceUnavailableException`
|
|
47
|
-
is a real failure somebody must see, and treating it as routine because it was explicit would hide the
|
|
48
|
-
case that matters most. Sending everything instead turns the issue list into a stream of invalid client
|
|
49
|
-
input until somebody mutes the project.
|
|
39
|
+
imported. The status comes from our mapping because a domain error carries a `kind`, not a status: a filter
|
|
40
|
+
reading the error alone would count a 404 as a 500 and fill the issue list with invalid client input.
|
|
50
41
|
|
|
51
|
-
**What reaches Sentry leaves your perimeter
|
|
52
|
-
|
|
53
|
-
higher than for logs, and why the body never goes: on an authentication route it is the password, on a
|
|
54
|
-
payment route it is the card. Reproduction is a convenience; a leak into someone else's system is an
|
|
55
|
-
incident.
|
|
42
|
+
**What reaches Sentry leaves your perimeter**, readable by anyone with access to that project. That is why
|
|
43
|
+
the body never goes: on an authentication route it is the password.
|
|
56
44
|
|
|
57
45
|
## Applies to
|
|
58
46
|
|
|
59
|
-
|
|
47
|
+
`server.ts`, the health controller, and `src/infra/observability/` once error reporting is installed.
|
|
60
48
|
|
|
61
49
|
## Examples
|
|
62
50
|
|
|
63
|
-
Health routes:
|
|
64
|
-
|
|
65
|
-
```
|
|
66
|
-
✅ GET /api/health/live → process is up, checks nothing
|
|
67
|
-
GET /api/health/ready → database answers
|
|
68
|
-
❌ GET /api/health → used for both
|
|
69
51
|
```
|
|
52
|
+
✅ GET /api/health/live process is up, checks nothing
|
|
53
|
+
GET /api/health/ready database answers
|
|
54
|
+
❌ GET /api/health used for both
|
|
70
55
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
```
|
|
74
|
-
✅ @Catch()
|
|
75
|
-
export class ProblemFilter implements ExceptionFilter {
|
|
76
|
-
@SentryExceptionCaptured()
|
|
77
|
-
catch(exception: unknown, host: ArgumentsHost) {}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
❌ app.useGlobalFilters(new SentryGlobalFilter(), new ProblemFilter())
|
|
56
|
+
✅ Sentry.fastifyIntegration({ shouldHandleError: (error) => statusOf(error) >= 500 })
|
|
57
|
+
❌ Sentry.setupFastifyErrorHandler(app)
|
|
81
58
|
```
|
|
82
59
|
|
|
83
60
|
## Enforcement
|
|
84
61
|
|
|
85
|
-
**Boot.** The health routes and the
|
|
86
|
-
|
|
87
|
-
**Review only, and this is the weakest line in the project:** that `instrument.ts` stays the first
|
|
88
|
-
import. Reordering imports for tidiness, or a formatter doing it unasked, silently stops the
|
|
89
|
-
instrumentation, and nothing fails.
|
|
62
|
+
**Boot.** The health routes are registered once, and the ORM connects before listening.
|
|
90
63
|
|
|
91
|
-
|
|
64
|
+
**Review only, and this is the weakest line in the project:** that `instrument.ts` stays the first import.
|
|
65
|
+
Reordering imports, or a formatter doing it unasked, silently stops the instrumentation, and nothing fails.
|
|
@@ -6,66 +6,51 @@ Paginate every list endpoint by cursor. The UUID v7 primary key is the cursor.
|
|
|
6
6
|
|
|
7
7
|
Order by id descending. `after` means *older than this*. Do not accept a sort parameter.
|
|
8
8
|
|
|
9
|
-
Accept two query parameters
|
|
10
|
-
|
|
9
|
+
Accept two query parameters in the route's `querystring` schema, a `z.strictObject`:
|
|
10
|
+
`limit: z.coerce.number().int().min(1).max(100).default(20)` and `after: z.uuid().optional()`.
|
|
11
11
|
|
|
12
|
-
Return `{ "data": [...], "nextCursor": "
|
|
13
|
-
|
|
12
|
+
Return `{ "data": [...], "nextCursor": "0199..." | null }`. Add nothing else: no `hasMore`, no `total`, no
|
|
13
|
+
nested `meta`.
|
|
14
14
|
|
|
15
15
|
Set `nextCursor` to the id of the last item on the page, or `null` when the page is the last one.
|
|
16
16
|
|
|
17
17
|
## Rationale
|
|
18
18
|
|
|
19
|
-
Offset pagination fails silently. Under concurrent inserts, page 2 repeats an item from page 1 or
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
id already is one: no extra sort column, no extra index.
|
|
19
|
+
Offset pagination fails silently. Under concurrent inserts, page 2 repeats an item from page 1 or skips one,
|
|
20
|
+
with no error, and it degrades at depth, because the database counts and discards every skipped row. A cursor
|
|
21
|
+
is stable at any depth, and UUID v7 being time-ordered means the id already is one.
|
|
23
22
|
|
|
24
|
-
That
|
|
25
|
-
|
|
26
|
-
shows.
|
|
23
|
+
That fixes the order: `WHERE id < :cursor ORDER BY id DESC` only works over one ordering, so a sort parameter
|
|
24
|
+
would break the cursor without erroring.
|
|
27
25
|
|
|
28
|
-
`hasMore` is omitted because a null `nextCursor` already says it
|
|
29
|
-
|
|
30
|
-
expensive part of an otherwise cheap endpoint, paid whether or not anyone reads it. Adding it to one
|
|
31
|
-
route later breaks no client.
|
|
26
|
+
`hasMore` is omitted because a null `nextCursor` already says it. `total` is omitted because it costs a
|
|
27
|
+
`COUNT` on every request, paid whether or not anyone reads it; adding it to one route later breaks no client.
|
|
32
28
|
|
|
33
|
-
The
|
|
34
|
-
|
|
35
|
-
without saying so, and the client would advance as though it had consumed 500.
|
|
29
|
+
The maximum lives in the schema rather than in clamping logic. Clamping would return 100 rows to a client
|
|
30
|
+
that asked for 500 without saying so.
|
|
36
31
|
|
|
37
32
|
## Applies to
|
|
38
33
|
|
|
39
|
-
Every endpoint returning a collection.
|
|
40
|
-
|
|
34
|
+
Every endpoint returning a collection. A filter is another field of the same query schema and composes with
|
|
35
|
+
the cursor.
|
|
41
36
|
|
|
42
37
|
## Examples
|
|
43
38
|
|
|
44
|
-
The response:
|
|
45
|
-
|
|
46
39
|
```
|
|
47
|
-
✅ { "data": [ ... ], "nextCursor": "
|
|
40
|
+
✅ { "data": [ ... ], "nextCursor": "0199..." }
|
|
48
41
|
❌ { "data": [ ... ], "meta": { "page": 2, "total": 3204, "hasMore": true } }
|
|
49
|
-
```
|
|
50
42
|
|
|
51
|
-
|
|
43
|
+
✅ limit: z.coerce.number().int().min(1).max(100).default(20)
|
|
44
|
+
❌ limit: z.coerce.number().default(20) no maximum: ?limit=1000000
|
|
52
45
|
|
|
53
|
-
|
|
54
|
-
✅ @Max(100) limit: number = 20
|
|
55
|
-
❌ limit: number = 20 // no maximum; ?limit=1000000 is a denial of service
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
Ordering:
|
|
59
|
-
|
|
60
|
-
```
|
|
61
|
-
✅ GET /api/v1/orders?limit=20&after=01JQ8Z...
|
|
46
|
+
✅ GET /api/v1/orders?limit=20&after=0199...
|
|
62
47
|
❌ GET /api/v1/orders?sort=total&page=3
|
|
63
48
|
```
|
|
64
49
|
|
|
65
50
|
## Enforcement
|
|
66
51
|
|
|
67
|
-
**Validation.** `limit`'s bounds are enforced by the
|
|
68
|
-
`offset`) is
|
|
52
|
+
**Validation.** `limit`'s bounds are enforced by the schema, and an unknown parameter (`page`, `sort`,
|
|
53
|
+
`offset`) is rejected by `z.strictObject`.
|
|
69
54
|
|
|
70
|
-
**Review only.** That `nextCursor` is the last item's id and is null on the final page, and that a new
|
|
71
|
-
|
|
55
|
+
**Review only.** That `nextCursor` is the last item's id and is null on the final page, and that a new list
|
|
56
|
+
endpoint paginates at all.
|
|
@@ -2,88 +2,107 @@
|
|
|
2
2
|
|
|
3
3
|
## Rule
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
Serve every route at `/api/v<n>/<resource>`: register each controller with
|
|
6
|
+
`prefix: '/api/v<n>/<resource>'`. Better Auth's `/api/auth/*`, `/api/health/*` and `/api/docs` are the only
|
|
7
|
+
routes outside a version.
|
|
7
8
|
|
|
8
|
-
Name resources in the plural, kebab-case for compound words. Use REST verbs
|
|
9
|
-
|
|
9
|
+
Name resources in the plural, kebab-case for compound words. Use REST verbs. A `POST` answers `201`
|
|
10
|
+
through `reply.code(201)`; every other method answers `200`.
|
|
10
11
|
|
|
11
|
-
|
|
12
|
+
Write one controller per module, `src/infra/http/controllers/<module>.controller.ts`, as a Fastify plugin
|
|
13
|
+
declared with `function`. Its handler reads the request, resolves one use case from the container, calls
|
|
14
|
+
`execute()` and returns the result. Nothing else happens in a handler.
|
|
12
15
|
|
|
13
|
-
|
|
14
|
-
`
|
|
16
|
+
Declare every route's schemas from the module's `dto/`: `body`, `querystring` and `params` for what enters,
|
|
17
|
+
`response` for what leaves, keyed by status. Write input schemas with `z.strictObject`, output schemas with
|
|
18
|
+
`z.object`. Convert a query or path value that is not a string with `z.coerce`, in the schema.
|
|
15
19
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
Write one output schema per shape the API exposes, named after the entity: `profile-response.dto.ts`. Every
|
|
21
|
+
route and every MCP tool returning that shape uses it. Write another only when the shape differs, and name
|
|
22
|
+
the difference: `profile-summary-response.dto.ts`. Declare dates in an output schema as `z.date()`.
|
|
19
23
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
Enable the `@nestjs/swagger` CLI plugin. Decorate by hand only what it cannot infer: error responses,
|
|
24
|
-
non-obvious status codes, route descriptions.
|
|
24
|
+
Tag every route with its module. Describe by hand only what a schema cannot say: a non-obvious status, an
|
|
25
|
+
error a route answers on purpose.
|
|
25
26
|
|
|
26
27
|
Serve the documentation UI outside production only.
|
|
27
28
|
|
|
28
29
|
## Rationale
|
|
29
30
|
|
|
30
|
-
The
|
|
31
|
-
|
|
31
|
+
The prefix makes Better Auth's `/api/auth/*` fall out of the ordinary convention instead of becoming the
|
|
32
|
+
one route with an exception in its path.
|
|
33
|
+
|
|
34
|
+
Versioning costs five characters today and a coordinated migration of every consumer later, including a
|
|
35
|
+
published mobile app that does not update when you want it to. It goes in the URI rather than a header
|
|
36
|
+
because a version in the URL appears in logs, caches, a `curl` line and an error report: exactly where you
|
|
37
|
+
look when v1 breaks.
|
|
32
38
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
exactly where you look when v1 breaks.
|
|
39
|
+
`z.strictObject` is what makes an unexpected property visible. Stripping it silently returns 200 to a
|
|
40
|
+
client that sent `role: "admin"`, which looks like success and hides the honest bug and the malicious
|
|
41
|
+
attempt equally.
|
|
37
42
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
43
|
+
On the way out the opposite is wanted, and `z.object` gives it: Fastify serializes through the response
|
|
44
|
+
schema and drops every field it does not list. The schema is therefore the list of what may leave. It is
|
|
45
|
+
written once per shape, because three schemas over one entity would each have to remember to leave out
|
|
46
|
+
`tenant_id`, and so would the fourth someone adds later. A route with no response schema would fall back
|
|
47
|
+
to `JSON.stringify` and publish every column, which is why the application refuses to start with one.
|
|
41
48
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
fourth someone adds later.
|
|
49
|
+
A date is `z.date()` because the entity holds a `Date` and Fastify writes it as ISO 8601. `z.iso.datetime()`
|
|
50
|
+
and `.transform()` both fail at runtime on a `Date`, with a 500.
|
|
45
51
|
|
|
46
|
-
The
|
|
47
|
-
|
|
48
|
-
|
|
52
|
+
The OpenAPI document is generated from the same schemas the routes validate with. A description written by
|
|
53
|
+
hand beside them is the copy nobody updates, and documentation that lies is worse than none, because
|
|
54
|
+
consumers trust it.
|
|
55
|
+
|
|
56
|
+
`201` on `POST` is the status code clients already receive: the rule was Nest's default before, and is
|
|
57
|
+
written down now that no framework supplies it.
|
|
49
58
|
|
|
50
59
|
## Applies to
|
|
51
60
|
|
|
52
|
-
Every controller under `src/`,
|
|
53
|
-
documentation are
|
|
61
|
+
Every controller under `src/infra/http/controllers/`, every schema under `src/domain/<module>/dto/`, and
|
|
62
|
+
`src/infra/http/app.ts`, where the prefixes, the compilers and the documentation are registered.
|
|
54
63
|
|
|
55
64
|
## Examples
|
|
56
65
|
|
|
57
66
|
Resource naming and versioning:
|
|
58
67
|
|
|
59
68
|
```
|
|
60
|
-
✅
|
|
61
|
-
❌
|
|
69
|
+
✅ app.register(billingAccountsController, { prefix: '/api/v1/billing-accounts' })
|
|
70
|
+
❌ app.register(billingAccountsController, { prefix: '/billingAccount' })
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
What enters and what leaves:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
✅ body: updateProfileSchema z.strictObject({ … })
|
|
77
|
+
response: { 200: profileResponseSchema }
|
|
78
|
+
❌ body: z.object({ … }) an unknown field passes, silently dropped
|
|
79
|
+
❌ { schema: { body: … } } no response: every column leaves
|
|
62
80
|
```
|
|
63
81
|
|
|
64
|
-
|
|
82
|
+
A value from the query string:
|
|
65
83
|
|
|
66
84
|
```
|
|
67
|
-
✅
|
|
68
|
-
❌
|
|
85
|
+
✅ page: z.coerce.number().int().min(1)
|
|
86
|
+
❌ page: z.number() always a string on the wire, always a 400
|
|
69
87
|
```
|
|
70
88
|
|
|
71
|
-
A
|
|
89
|
+
A handler that delegates:
|
|
72
90
|
|
|
73
91
|
```
|
|
74
|
-
✅
|
|
75
|
-
❌
|
|
76
|
-
const
|
|
77
|
-
if (
|
|
78
|
-
return
|
|
92
|
+
✅ async (request) => app.container.resolve(FindProfileUseCase).execute(currentUser(request).id)
|
|
93
|
+
❌ async (request) => {
|
|
94
|
+
const profile = await app.container.resolve(FindProfileUseCase).execute(id)
|
|
95
|
+
if (profile.plan === 'trial' && profile.createdAt < cutoff) profile.plan = 'expired'
|
|
96
|
+
return profile
|
|
79
97
|
}
|
|
80
98
|
```
|
|
81
99
|
|
|
82
100
|
## Enforcement
|
|
83
101
|
|
|
84
|
-
**Boot.**
|
|
85
|
-
|
|
102
|
+
**Boot.** `src/infra/http/app.ts` refuses to start when a route under `/api/v` declares no response
|
|
103
|
+
schema, and names it. The compilers, the prefix and the documentation are registered once there, so a
|
|
104
|
+
route cannot opt out of them by accident.
|
|
86
105
|
|
|
87
|
-
**Review only.** Resource naming,
|
|
88
|
-
|
|
89
|
-
|
|
106
|
+
**Review only.** Resource naming, the thinness of a handler, `201` on `POST`, `z.strictObject` on input,
|
|
107
|
+
and (most importantly) whether a new sensitive column has stayed out of the output schema. The schema
|
|
108
|
+
lists what leaves, so a field is only published when somebody adds it to that list.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Structure
|
|
2
|
+
|
|
3
|
+
## Rule
|
|
4
|
+
|
|
5
|
+
Split `src/` into two layers. `src/domain` holds what the product is: entities, ports, use cases, DTOs and
|
|
6
|
+
errors. `src/infra` holds how it runs: configuration, auth, the database adapters, the HTTP edge and the
|
|
7
|
+
dependency container.
|
|
8
|
+
|
|
9
|
+
Import nothing from `src/infra`, from the ORM or from the HTTP framework inside `src/domain`. The domain
|
|
10
|
+
imports `zod` for its DTOs and `tsyringe` for its decorators, and nothing else outside itself, except by
|
|
11
|
+
name: an entity declaring a relation imports `Ref`, `ref` and `Collection` from `@mikro-orm/core`, and
|
|
12
|
+
nothing more.
|
|
13
|
+
|
|
14
|
+
Create one module per resource under `src/domain`: `users`, `orders`, `invoices`. A module holds only these
|
|
15
|
+
folders, each created with its first file:
|
|
16
|
+
|
|
17
|
+
| Folder | Holds | File |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| `entities/` | A plain class with the data and the behaviour of one entity | `<name>.entity.ts` |
|
|
20
|
+
| `repositories/` | The port: an interface and its `Symbol` token, in one file | `<name>.repository.ts` |
|
|
21
|
+
| `use-cases/` | One class per use case, with one `execute()` method | `<verb>-<noun>.use-case.ts` |
|
|
22
|
+
| `dto/` | Zod schemas and their inferred types, input and output | `<name>.dto.ts` |
|
|
23
|
+
| `errors/` | Classes extending `DomainError` | `<name>.error.ts` |
|
|
24
|
+
| `services/` | A pure function spanning several entities of the module | `<name>.service.ts` |
|
|
25
|
+
|
|
26
|
+
Put logic about one entity in a method of that entity. Put logic spanning several in an exported function
|
|
27
|
+
under `services/`. A pure function is imported directly and never registered in the container.
|
|
28
|
+
|
|
29
|
+
For every port, write its adapter under `src/infra` and register it in `src/infra/di/<module>.di.ts`, which
|
|
30
|
+
`src/infra/di/index.ts` calls.
|
|
31
|
+
|
|
32
|
+
Name every file with its role suffix, including where the folder already says it.
|
|
33
|
+
|
|
34
|
+
Reach another module only through its use cases: inject the use case class. A module's ports are its own;
|
|
35
|
+
no other module injects them.
|
|
36
|
+
|
|
37
|
+
Two modules never depend on each other in both directions, and neither do two use cases. When they appear
|
|
38
|
+
to, move the common part into a third module named for what it does. Do not use tsyringe's `delay()`.
|
|
39
|
+
|
|
40
|
+
Do not write barrel files. An `index.ts` that holds code of its own, as `src/infra/di/index.ts` does, is not
|
|
41
|
+
one.
|
|
42
|
+
|
|
43
|
+
Put in `src/domain/shared/` only a primitive every module uses or a second module has come to need, in a
|
|
44
|
+
folder named for its role: `errors/`, `transactions/`. A capability with a name of its own, such as
|
|
45
|
+
`notifications`, `pdf` or `storage`, is a module, not a shared file.
|
|
46
|
+
|
|
47
|
+
## Rationale
|
|
48
|
+
|
|
49
|
+
The layers exist so the rules can be read and tested without a database or a server. A use case receives
|
|
50
|
+
its ports through the constructor, so its test hands it an in-memory fake; nothing about Postgres or
|
|
51
|
+
Fastify is needed to prove a rule of the product. The rule is kept by convention rather than by a checker,
|
|
52
|
+
a choice made knowingly: the one checker considered was measured once producing a useless file to satisfy
|
|
53
|
+
itself.
|
|
54
|
+
|
|
55
|
+
One class per use case keeps each constructor to what that case needs. A service per module accumulates
|
|
56
|
+
the dependencies of every case in one constructor and grows without a limit.
|
|
57
|
+
|
|
58
|
+
The suffix is always written because the same name lives in several layers. `update-profile` is both a
|
|
59
|
+
DTO and a use case; `profile` is an entity, a schema, a port and an adapter. Without the suffix, an editor
|
|
60
|
+
shows four tabs named `profile.ts` and a file search cannot tell them apart. A rule of *suffix where it
|
|
61
|
+
collides* would need judging at every new file and would rename old files when a new one arrived.
|
|
62
|
+
|
|
63
|
+
Reaching another module through its use cases means the module that owns an entity is the only one that
|
|
64
|
+
writes it, so its rules cannot be bypassed from outside. A cycle is refused because tsyringe's answer to
|
|
65
|
+
one, `delay()`, is presented in its own documentation as the way out of an `undefined constructor` error:
|
|
66
|
+
a patch over a design already in a loop. Barrel files are refused because they are how an invisible cycle
|
|
67
|
+
forms: a file imports the index that imports it back.
|
|
68
|
+
|
|
69
|
+
A folder exists only with its first file because Git does not keep an empty one, and a folder waiting to
|
|
70
|
+
be filled is filled for its own sake. `domain/shared/` has an admission test because a shared folder
|
|
71
|
+
without one only grows; requiring a role for each folder forces the question *what is this*.
|
|
72
|
+
|
|
73
|
+
**What this costs:** a module that is large and still one thing gets many files, one per use case. And
|
|
74
|
+
nothing but review keeps the domain free of the infrastructure.
|
|
75
|
+
|
|
76
|
+
## Applies to
|
|
77
|
+
|
|
78
|
+
Every file under `src/` of the API. The rules about reaching across modules apply wherever one module's
|
|
79
|
+
code refers to another's.
|
|
80
|
+
|
|
81
|
+
## Examples
|
|
82
|
+
|
|
83
|
+
The layers:
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
✅ src/domain/orders/use-cases/create-order.use-case.ts imports @/domain/…
|
|
87
|
+
❌ src/domain/orders/use-cases/create-order.use-case.ts imports @mikro-orm/core
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
A module:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
✅ domain/users/entities/profile.entity.ts
|
|
94
|
+
domain/users/repositories/profile.repository.ts interface + PROFILE_REPOSITORY
|
|
95
|
+
domain/users/use-cases/update-profile.use-case.ts
|
|
96
|
+
domain/users/dto/update-profile.dto.ts
|
|
97
|
+
❌ domain/users/users.service.ts every case in one class
|
|
98
|
+
❌ domain/users/profile.ts no role suffix
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Reaching another module:
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
✅ constructor(private readonly findProfile: FindProfileUseCase) {}
|
|
105
|
+
❌ constructor(@inject(PROFILE_REPOSITORY) private readonly profiles: ProfileRepository) {} from orders
|
|
106
|
+
❌ constructor(@inject(delay(() => CreateOrderUseCase)) …)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Where logic goes:
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
✅ profile.update(changes) one entity
|
|
113
|
+
✅ domain/orders/services/order-total.service.ts several entities
|
|
114
|
+
❌ a private function copied into two use cases
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Importing:
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
✅ import { FindProfileUseCase } from '@/domain/users/use-cases/find-profile.use-case'
|
|
121
|
+
❌ import { FindProfileUseCase } from '@/domain/users'
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Enforcement
|
|
125
|
+
|
|
126
|
+
**Lint.** Biome's `noBarrelFile`, enabled in the API's `biome.jsonc`, refuses a file that re-exports.
|
|
127
|
+
|
|
128
|
+
**Review only.** Everything else. Nothing checks that the domain imports no infrastructure, that a module
|
|
129
|
+
maps to a resource, that another module is reached through its use cases, that no cycle has formed, or
|
|
130
|
+
that `domain/shared/` admits only primitives; a reviewer does.
|
|
@@ -38,9 +38,10 @@ API renames a field, the client keeps compiling against its own copy, and the fa
|
|
|
38
38
|
`undefined` on a screen.
|
|
39
39
|
|
|
40
40
|
**Importing the entity type from the API package looks like the obvious fix and is unsound.** Responses
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
carries it: a type that lies, which is worse
|
|
41
|
+
pass through the route's output schema, which drops every field it does not list, so a property can remain
|
|
42
|
+
on the entity type while missing from the JSON; and a `Date` on the entity arrives as an ISO string. The
|
|
43
|
+
shared type would promise `tenantId` on a payload that never carries it: a type that lies, which is worse
|
|
44
|
+
than the duplication it removes.
|
|
44
45
|
|
|
45
46
|
**The contract lives in one directory, not beside each feature, so that one template serves both
|
|
46
47
|
architectures.** The monorepo form moves `api-contract/` whole into `packages/api-contract` and changes one
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
## Rule
|
|
4
4
|
|
|
5
|
-
Name every source file in kebab-case
|
|
6
|
-
`
|
|
5
|
+
Name every source file in kebab-case. In `api`, add the role suffix, always: `profile.entity.ts`,
|
|
6
|
+
`update-profile.use-case.ts`, `update-profile.dto.ts`, `users.controller.ts`. A component is
|
|
7
|
+
`button.tsx`.
|
|
7
8
|
|
|
8
9
|
Write a comment only when it carries a fact the code cannot carry about itself. Before writing one, ask
|
|
9
10
|
whether a careful reader could recover it anyway. If they could, delete it.
|
|
@@ -19,9 +20,11 @@ tag or a change log; git holds all three.
|
|
|
19
20
|
|
|
20
21
|
## Rationale
|
|
21
22
|
|
|
22
|
-
kebab-case is what
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
kebab-case is what shadcn's CLI emits, so anything else adds a rename step after every generation, and a
|
|
24
|
+
correction after generation is what people forget. The suffix is always written because in `api` the same
|
|
25
|
+
name lives in several layers: `update-profile` is a DTO and a use case, `profile` an entity, a schema, a port
|
|
26
|
+
and an adapter, and without it an editor shows four tabs called `profile.ts`. kebab-case also never touches
|
|
27
|
+
a problem PascalCase lives with: macOS and Windows are case-insensitive, so renaming `OrderList.tsx` to
|
|
25
28
|
`orderList.tsx` does not register as a change in git, and the file ends up cased differently in the
|
|
26
29
|
repository and on a Linux clone.
|
|
27
30
|
|
|
@@ -44,8 +47,8 @@ Every source file, in every type.
|
|
|
44
47
|
Naming:
|
|
45
48
|
|
|
46
49
|
```
|
|
47
|
-
✅ create-user.dto.ts
|
|
48
|
-
❌ CreateUserDto.ts
|
|
50
|
+
✅ create-user.dto.ts create-user.use-case.ts order-list.tsx
|
|
51
|
+
❌ CreateUserDto.ts create-user.ts OrderList.tsx
|
|
49
52
|
```
|
|
50
53
|
|
|
51
54
|
A comment that earns its place:
|