@stjoseph/prumo 0.0.1
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 +130 -0
- package/.prumo-templates/api/config.md +85 -0
- package/.prumo-templates/api/errors.md +82 -0
- package/.prumo-templates/api/logging.md +79 -0
- package/.prumo-templates/api/modules.md +117 -0
- package/.prumo-templates/api/observability.md +91 -0
- package/.prumo-templates/api/pagination.md +71 -0
- package/.prumo-templates/api/routes.md +89 -0
- package/.prumo-templates/client/data.md +124 -0
- package/.prumo-templates/client/forms.md +88 -0
- package/.prumo-templates/core/conventions.md +74 -0
- package/.prumo-templates/core/testing.md +100 -0
- package/.prumo-templates/core/tooling.md +118 -0
- package/.prumo-templates/database/entities.md +97 -0
- package/.prumo-templates/database/migrations.md +74 -0
- package/.prumo-templates/database/queries.md +79 -0
- package/.prumo-templates/database/testing.md +82 -0
- package/.prumo-templates/database/transactions.md +68 -0
- package/.prumo-templates/mobile/components.md +83 -0
- package/.prumo-templates/mobile/config.md +84 -0
- package/.prumo-templates/mobile/routing.md +74 -0
- package/.prumo-templates/mobile/storage.md +84 -0
- package/.prumo-templates/mobile/structure.md +79 -0
- package/.prumo-templates/monorepo/dependencies.md +84 -0
- package/.prumo-templates/monorepo/layout.md +89 -0
- package/.prumo-templates/monorepo/tasks.md +76 -0
- package/.prumo-templates/multi-tenancy/client.md +83 -0
- package/.prumo-templates/multi-tenancy/isolation.md +104 -0
- package/.prumo-templates/site/rendering.md +83 -0
- package/.prumo-templates/site/structure.md +68 -0
- package/.prumo-templates/web/components.md +106 -0
- package/.prumo-templates/web/routing.md +82 -0
- package/.prumo-templates/web/structure.md +92 -0
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/dist/asker.js +28 -0
- package/dist/cli.js +62 -0
- package/dist/compose.js +222 -0
- package/dist/context.js +38 -0
- package/dist/generate.js +60 -0
- package/dist/jsonc.js +76 -0
- package/dist/names.js +10 -0
- package/dist/questions.js +63 -0
- package/package.json +55 -0
- package/templates/api/.env.example +12 -0
- package/templates/api/.githooks/install.mjs +18 -0
- package/templates/api/.githooks/pre-commit +2 -0
- package/templates/api/.githooks/pre-push +2 -0
- package/templates/api/README.md +35 -0
- package/templates/api/biome.jsonc +18 -0
- package/templates/api/docker/init/01-auth-schema.sql +1 -0
- package/templates/api/docker-compose.yml +20 -0
- package/templates/api/gitignore +5 -0
- package/templates/api/migrations/Migration20260914123239_create_profile.ts +13 -0
- package/templates/api/nest-cli.json +10 -0
- package/templates/api/package.json +69 -0
- package/templates/api/pnpm-workspace.yaml +9 -0
- package/templates/api/src/app.module.ts +46 -0
- package/templates/api/src/auth/auth.constants.ts +2 -0
- package/templates/api/src/auth/auth.controller.ts +19 -0
- package/templates/api/src/auth/auth.factory.spec.ts +29 -0
- package/templates/api/src/auth/auth.factory.ts +52 -0
- package/templates/api/src/auth/auth.guard.ts +43 -0
- package/templates/api/src/auth/auth.module.ts +30 -0
- package/templates/api/src/auth/auth.ts +4 -0
- package/templates/api/src/auth/current-user.decorator.ts +9 -0
- package/templates/api/src/auth/express.d.ts +10 -0
- package/templates/api/src/auth/public.decorator.ts +4 -0
- package/templates/api/src/config/env.module.ts +11 -0
- package/templates/api/src/config/env.ts +81 -0
- package/templates/api/src/errors/problem-details.filter.ts +86 -0
- package/templates/api/src/errors/validation-exception.factory.ts +30 -0
- package/templates/api/src/health/health.controller.ts +32 -0
- package/templates/api/src/health/health.module.ts +9 -0
- package/templates/api/src/main.ts +43 -0
- package/templates/api/src/mikro-orm.config.ts +4 -0
- package/templates/api/src/mikro-orm.factory.ts +14 -0
- package/templates/api/src/request-context/request-context.module.ts +9 -0
- package/templates/api/src/request-context/request-context.service.ts +17 -0
- package/templates/api/src/request-context/request-id.middleware.ts +19 -0
- package/templates/api/src/users/dto/update-profile.dto.ts +20 -0
- package/templates/api/src/users/entities/profile.entity.ts +27 -0
- package/templates/api/src/users/users.controller.ts +23 -0
- package/templates/api/src/users/users.module.ts +10 -0
- package/templates/api/src/users/users.service.spec.ts +44 -0
- package/templates/api/src/users/users.service.ts +36 -0
- package/templates/api/test/factories/profile.factory.ts +26 -0
- package/templates/api/test/factories/user.factory.ts +21 -0
- package/templates/api/test/global-setup.ts +20 -0
- package/templates/api/test/setup.ts +80 -0
- package/templates/api/tsconfig.build.json +8 -0
- package/templates/api/tsconfig.json +29 -0
- package/templates/api/vitest.config.mts +17 -0
- package/templates/mobile/.env.example +1 -0
- package/templates/mobile/.githooks/install.mjs +18 -0
- package/templates/mobile/.githooks/pre-commit +2 -0
- package/templates/mobile/.githooks/pre-push +2 -0
- package/templates/mobile/README.md +30 -0
- package/templates/mobile/app.config.ts +13 -0
- package/templates/mobile/app.json +39 -0
- package/templates/mobile/assets/expo.icon/Assets/expo-symbol 2.svg +3 -0
- package/templates/mobile/assets/expo.icon/Assets/grid.png +0 -0
- package/templates/mobile/assets/expo.icon/icon.json +35 -0
- package/templates/mobile/assets/images/android-icon-background.png +0 -0
- package/templates/mobile/assets/images/android-icon-foreground.png +0 -0
- package/templates/mobile/assets/images/android-icon-monochrome.png +0 -0
- package/templates/mobile/assets/images/icon.png +0 -0
- package/templates/mobile/assets/images/splash-icon.png +0 -0
- package/templates/mobile/babel.config.js +6 -0
- package/templates/mobile/biome.jsonc +21 -0
- package/templates/mobile/gitignore +11 -0
- package/templates/mobile/global.d.ts +1 -0
- package/templates/mobile/jest.config.js +21 -0
- package/templates/mobile/metro.config.js +4 -0
- package/templates/mobile/nativewind-env.d.ts +1 -0
- package/templates/mobile/package.json +63 -0
- package/templates/mobile/src/api-contract/api-error.ts +51 -0
- package/templates/mobile/src/api-contract/client.ts +26 -0
- package/templates/mobile/src/api-contract/index.ts +10 -0
- package/templates/mobile/src/api-contract/profile.ts +27 -0
- package/templates/mobile/src/app/(app)/_layout.tsx +18 -0
- package/templates/mobile/src/app/(app)/index.tsx +18 -0
- package/templates/mobile/src/app/(auth)/sign-in.tsx +24 -0
- package/templates/mobile/src/app/(auth)/sign-up.tsx +23 -0
- package/templates/mobile/src/app/_layout.tsx +54 -0
- package/templates/mobile/src/components/ui/button.tsx +36 -0
- package/templates/mobile/src/components/ui/field.tsx +30 -0
- package/templates/mobile/src/config.ts +3 -0
- package/templates/mobile/src/features/auth/auth-client.ts +27 -0
- package/templates/mobile/src/features/auth/intended-route.ts +11 -0
- package/templates/mobile/src/features/auth/session.ts +17 -0
- package/templates/mobile/src/features/auth/sign-in-form.spec.tsx +37 -0
- package/templates/mobile/src/features/auth/sign-in-form.tsx +80 -0
- package/templates/mobile/src/features/auth/sign-up-form.tsx +99 -0
- package/templates/mobile/src/features/clients/clients-context.tsx +21 -0
- package/templates/mobile/src/features/forms/form-error.tsx +16 -0
- package/templates/mobile/src/features/forms/server-errors.ts +33 -0
- package/templates/mobile/src/features/profile/profile-form.spec.tsx +58 -0
- package/templates/mobile/src/features/profile/profile-form.tsx +96 -0
- package/templates/mobile/src/features/storage/query-persister.ts +15 -0
- package/templates/mobile/src/features/storage/storage.ts +11 -0
- package/templates/mobile/src/global.css +3 -0
- package/templates/mobile/tailwind.config.js +9 -0
- package/templates/mobile/test/fake-transport.ts +23 -0
- package/templates/mobile/tsconfig.json +21 -0
- package/templates/site/.githooks/install.mjs +18 -0
- package/templates/site/.githooks/pre-commit +2 -0
- package/templates/site/.githooks/pre-push +2 -0
- package/templates/site/README.md +28 -0
- package/templates/site/biome.jsonc +12 -0
- package/templates/site/components.json +25 -0
- package/templates/site/gitignore +7 -0
- package/templates/site/next.config.ts +7 -0
- package/templates/site/package.json +40 -0
- package/templates/site/pnpm-workspace.yaml +5 -0
- package/templates/site/postcss.config.mjs +7 -0
- package/templates/site/src/app/globals.css +129 -0
- package/templates/site/src/app/layout.tsx +13 -0
- package/templates/site/src/app/page.spec.ts +10 -0
- package/templates/site/src/app/page.tsx +17 -0
- package/templates/site/src/lib/utils.ts +1 -0
- package/templates/site/tsconfig.json +34 -0
- package/templates/site/vitest.config.mts +8 -0
- package/templates/web/.env.example +1 -0
- package/templates/web/.githooks/install.mjs +18 -0
- package/templates/web/.githooks/pre-commit +2 -0
- package/templates/web/.githooks/pre-push +2 -0
- package/templates/web/README.md +29 -0
- package/templates/web/biome.jsonc +24 -0
- package/templates/web/components.json +25 -0
- package/templates/web/gitignore +6 -0
- package/templates/web/index.html +12 -0
- package/templates/web/package.json +55 -0
- package/templates/web/src/api-contract/api-error.ts +51 -0
- package/templates/web/src/api-contract/client.ts +26 -0
- package/templates/web/src/api-contract/index.ts +10 -0
- package/templates/web/src/api-contract/profile.ts +27 -0
- package/templates/web/src/app.spec.tsx +12 -0
- package/templates/web/src/app.tsx +14 -0
- package/templates/web/src/components/ui/button.tsx +57 -0
- package/templates/web/src/components/ui/field.tsx +224 -0
- package/templates/web/src/components/ui/input.tsx +19 -0
- package/templates/web/src/components/ui/label.tsx +17 -0
- package/templates/web/src/components/ui/separator.tsx +20 -0
- package/templates/web/src/config.ts +7 -0
- package/templates/web/src/features/auth/auth-client.ts +12 -0
- package/templates/web/src/features/auth/session.ts +17 -0
- package/templates/web/src/features/auth/sign-in-form.spec.tsx +30 -0
- package/templates/web/src/features/auth/sign-in-form.tsx +73 -0
- package/templates/web/src/features/auth/sign-up-form.tsx +79 -0
- package/templates/web/src/features/forms/form-error.tsx +15 -0
- package/templates/web/src/features/forms/server-errors.ts +33 -0
- package/templates/web/src/features/profile/profile-form.spec.tsx +58 -0
- package/templates/web/src/features/profile/profile-form.tsx +74 -0
- package/templates/web/src/index.css +130 -0
- package/templates/web/src/lib/utils.ts +1 -0
- package/templates/web/src/main.tsx +28 -0
- package/templates/web/src/routeTree.gen.ts +125 -0
- package/templates/web/src/router.tsx +21 -0
- package/templates/web/src/routes/__root.tsx +10 -0
- package/templates/web/src/routes/_authenticated/index.tsx +21 -0
- package/templates/web/src/routes/_authenticated.tsx +15 -0
- package/templates/web/src/routes/sign-in.tsx +20 -0
- package/templates/web/src/routes/sign-up.tsx +17 -0
- package/templates/web/test/fake-transport.ts +23 -0
- package/templates/web/test/render-app.tsx +17 -0
- package/templates/web/test/setup.ts +7 -0
- package/templates/web/tsconfig.json +21 -0
- package/templates/web/vite.config.ts +18 -0
- package/templates/workspace/.githooks/install.mjs +18 -0
- package/templates/workspace/.githooks/pre-commit +2 -0
- package/templates/workspace/.githooks/pre-push +2 -0
- package/templates/workspace/README.md +25 -0
- package/templates/workspace/biome.jsonc +8 -0
- package/templates/workspace/gitignore +5 -0
- package/templates/workspace/package.json +17 -0
- package/templates/workspace/packages/api-contract/package.json +20 -0
- package/templates/workspace/packages/api-contract/tsconfig.json +11 -0
- package/templates/workspace/pnpm-workspace.yaml +3 -0
- package/templates/workspace/tsconfig.base.json +10 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Authentication
|
|
2
|
+
|
|
3
|
+
## Rule
|
|
4
|
+
|
|
5
|
+
Mount Better Auth on a Nest controller: a catch-all route `@All('*path')` under `auth`, declared
|
|
6
|
+
`version: VERSION_NEUTRAL`, passing the request and response to `toNodeHandler(auth)`. With the global `api`
|
|
7
|
+
prefix this resolves to `/api/auth/*`. Mark that controller `@Public()`. It is the one controller that takes
|
|
8
|
+
`@Req()` and `@Res()`.
|
|
9
|
+
|
|
10
|
+
Register the authentication guard globally. Every route is protected.
|
|
11
|
+
|
|
12
|
+
Declare a route that does not require a session with `@Public()`. There is no third state: the guard
|
|
13
|
+
always attempts to resolve the session, and `@Public()` means only *do not return 401*.
|
|
14
|
+
|
|
15
|
+
Read the session with a `@CurrentUser()` parameter decorator. Pass it to the service as an ordinary
|
|
16
|
+
argument. Do not inject a request-scoped session provider, do not take `@Req()`, and do not read the
|
|
17
|
+
session from ambient context inside a service.
|
|
18
|
+
|
|
19
|
+
Keep Better Auth's tables in their own Postgres schema, on their own connection, migrated by its own
|
|
20
|
+
CLI. Point that CLI at the **compiled** configuration, and run its migrations **before** ours.
|
|
21
|
+
|
|
22
|
+
Set `advanced.database.generateId` to `'uuid'`.
|
|
23
|
+
|
|
24
|
+
Better Auth's tables follow Better Auth's conventions, not `database/entities.md`. Reference `auth.user(id)`
|
|
25
|
+
from an application table with a real foreign key and `ON DELETE RESTRICT`.
|
|
26
|
+
|
|
27
|
+
Create what the application keeps about a user in `databaseHooks.user.create.after`. Pass that hook into the
|
|
28
|
+
factory that builds Better Auth, so the factory does not depend on Nest.
|
|
29
|
+
|
|
30
|
+
## Rationale
|
|
31
|
+
|
|
32
|
+
`VERSION_NEUTRAL` keeps the auth routes out of the version segment, because you version what you own
|
|
33
|
+
and the shape of these endpoints is Better Auth's contract, not this project's. Mounting inside Nest
|
|
34
|
+
rather than as middleware ahead of the router means the logging and the request id apply to the
|
|
35
|
+
authentication flow, the path where investigating matters most. **The exception filter does not:**
|
|
36
|
+
`toNodeHandler` writes Better Auth's own responses, so an authentication error arrives as
|
|
37
|
+
`{ message, code }`, not as problem+json. The client converts it; the server does not rewrite another
|
|
38
|
+
library's responses.
|
|
39
|
+
|
|
40
|
+
`toNodeHandler` rather than `auth.handler(req)`, because `handler` expects a Fetch API `Request` and Nest
|
|
41
|
+
hands the controller an Express one; `toNodeHandler` is Better Auth's own adapter between the two. Running it
|
|
42
|
+
after Nest's body parser was expected to break sign-up and does not: verified with a real sign-up through the
|
|
43
|
+
controller.
|
|
44
|
+
|
|
45
|
+
The CLI reads the compiled file because it loads TypeScript through its own transpiler, which cannot handle
|
|
46
|
+
the legacy property decorators the validated environment class uses. Migrations before ours, because an
|
|
47
|
+
application table references Better Auth's.
|
|
48
|
+
|
|
49
|
+
**Its ids are `text` by default**, which no application column typed `uuid` can reference. `generateId:
|
|
50
|
+
'uuid'` makes them `uuid` columns. What remains different is deliberate and not ours to fix: camelCase column
|
|
51
|
+
names, and `gen_random_uuid()` (version 4) rather than `uuidv7()`. Rewriting another library's schema is
|
|
52
|
+
a migration fighting its CLI on every upgrade.
|
|
53
|
+
|
|
54
|
+
The foreign key into `auth.user` exists because between the database guaranteeing and the application
|
|
55
|
+
remembering, this project chooses the database. **What it costs:** deleting a user fails while rows reference
|
|
56
|
+
it, so the application removes them first, deliberately.
|
|
57
|
+
|
|
58
|
+
The hook is Better Auth's extension point for work that must follow sign-up, and it is the only place where a
|
|
59
|
+
client never learns that identity and profile are two tables. **It is not atomic:** the user and the
|
|
60
|
+
application's row are written over two connections, so a failure between them leaves a user with no row. The
|
|
61
|
+
result is a visible 404, not corrupted data.
|
|
62
|
+
|
|
63
|
+
The guard is global because of the asymmetry between the two possible mistakes. A forgotten guard
|
|
64
|
+
leaves an endpoint open: nothing errors, nothing breaks, and the route test still passes, because
|
|
65
|
+
route tests rarely assert refusal. A forgotten `@Public()` returns 401 on the first call and is fixed
|
|
66
|
+
in seconds.
|
|
67
|
+
|
|
68
|
+
The session resolves even on public routes so that "works signed out, shows more signed in" needs no
|
|
69
|
+
third decorator. With no cookie there is no lookup, so an anonymous request to a public endpoint pays
|
|
70
|
+
nothing.
|
|
71
|
+
|
|
72
|
+
The session arrives as an argument so that a service's signature says what it needs. Ambient context
|
|
73
|
+
would hide the dependency and force every test to construct a context before it can run.
|
|
74
|
+
|
|
75
|
+
## Applies to
|
|
76
|
+
|
|
77
|
+
Every controller and every service. The guard, the Better Auth controller and its connection are
|
|
78
|
+
registered once at bootstrap.
|
|
79
|
+
|
|
80
|
+
## Examples
|
|
81
|
+
|
|
82
|
+
Declaring access:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
✅ @Public() @Get('health') health() {}
|
|
86
|
+
❌ @Get('health') health() {} // 401, because protected is the default
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Mounting the handler:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
✅ @Public() @All('*path') handle(@Req() req: Request, @Res() res: Response) {
|
|
93
|
+
return toNodeHandler(this.auth)(req, res)
|
|
94
|
+
}
|
|
95
|
+
❌ @All('*') handle(@Req() req: Request) { return this.auth.handler(req) }
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Referencing a user from an application table:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
✅ foreign key ("user_id") references "auth"."user" ("id") on delete restrict
|
|
102
|
+
❌ "user_id" uuid not null // nothing stops an orphan
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Receiving the session:
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
✅ findMine(@CurrentUser() user: SessionUser) { return this.orders.forUser(user.id) }
|
|
109
|
+
❌ findMine(@Req() req: Request) { return this.orders.forUser(req.session.user.id) }
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Inside a service:
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
✅ forUser(userId: string) {}
|
|
116
|
+
❌ forUser() { const userId = context.get('userId') }
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Enforcement
|
|
120
|
+
|
|
121
|
+
**Boot.** The guard is global, so a route cannot be unprotected by omission, only by an explicit
|
|
122
|
+
`@Public()`.
|
|
123
|
+
|
|
124
|
+
**The database.** A row referencing a user that does not exist is refused.
|
|
125
|
+
|
|
126
|
+
**The migration script.** One command runs Better Auth's migrations and then ours, so the order cannot be
|
|
127
|
+
forgotten.
|
|
128
|
+
|
|
129
|
+
**Review only.** That each `@Public()` is deliberate. It is the one decorator in the system whose
|
|
130
|
+
mistaken use is invisible at runtime: the route works, and it works for everyone.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Configuration
|
|
2
|
+
|
|
3
|
+
## Rule
|
|
4
|
+
|
|
5
|
+
Validate the environment with `@nestjs/config`'s `validate` function, using class-validator. Do not
|
|
6
|
+
add Joi.
|
|
7
|
+
|
|
8
|
+
Register the validated class as a provider and inject it with its type. Do not call
|
|
9
|
+
`ConfigService.get()` with a string key.
|
|
10
|
+
|
|
11
|
+
Fail the boot when validation fails. Report every invalid variable at once, ignore unknown ones, and
|
|
12
|
+
name the variable without printing its value.
|
|
13
|
+
|
|
14
|
+
Keep `.env` in `.gitignore`. Commit `.env.example` with every variable and a safe sample value. Do not
|
|
15
|
+
read an env file in production.
|
|
16
|
+
|
|
17
|
+
Load `.env` outside production with Node's `process.loadEnvFile`, in the environment module, before
|
|
18
|
+
validating. Give `ConfigModule` `ignoreEnvFile: true` and that loading function as its `validate`.
|
|
19
|
+
|
|
20
|
+
Read `process.env` in exactly one place: the environment module. Nowhere else.
|
|
21
|
+
|
|
22
|
+
## Rationale
|
|
23
|
+
|
|
24
|
+
class-validator is already installed for DTOs, so using it here means one validation vocabulary
|
|
25
|
+
instead of two doing the same kind of work. Joi is genuinely better at configuration schemas
|
|
26
|
+
(coercion and defaults read more naturally there) but not enough to justify a second grammar in the
|
|
27
|
+
same project.
|
|
28
|
+
|
|
29
|
+
Injecting the validated class rather than calling `ConfigService.get()` keeps the type honest. `get()`
|
|
30
|
+
returns `string | undefined`, so every call site needs a `!` or a guard for a value that was already
|
|
31
|
+
proven present at boot, and the key is a string nothing checks: rename the variable, miss one call,
|
|
32
|
+
and it compiles and breaks in production. A class property breaks the build instead.
|
|
33
|
+
|
|
34
|
+
Reporting every failure at once turns eight missing variables into one cycle rather than eight. The
|
|
35
|
+
value is never printed because most of these are secrets and a boot error goes to the orchestrator's
|
|
36
|
+
log, which usually has more readers than the database.
|
|
37
|
+
|
|
38
|
+
**Unknown variables are ignored here, and that is deliberate.** Rejecting the unknown is the rule for
|
|
39
|
+
request bodies and query parameters; a process environment legitimately carries `PATH`, `HOME` and
|
|
40
|
+
whatever the CI runner sets, so the same rule would make the application impossible to start. This
|
|
41
|
+
exception is written down so nobody harmonises it later.
|
|
42
|
+
|
|
43
|
+
**The file is loaded by the environment module rather than by `ConfigModule`** because the application is not
|
|
44
|
+
the only entry point. The ORM's CLI and Better Auth's CLI both import the configuration long before any Nest
|
|
45
|
+
module exists, so `ConfigModule`'s own loading reaches the application and neither CLI. Node's `--env-file`
|
|
46
|
+
would do it without code, but it has to be typed on every command, and Node refuses it inside
|
|
47
|
+
`NODE_OPTIONS`. One function every entry point calls keeps one policy, including *never in production*.
|
|
48
|
+
|
|
49
|
+
## Applies to
|
|
50
|
+
|
|
51
|
+
The configuration module and every consumer of a setting.
|
|
52
|
+
|
|
53
|
+
## Examples
|
|
54
|
+
|
|
55
|
+
Reading a setting:
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
✅ constructor(private readonly config: AppConfig) {}
|
|
59
|
+
this.config.databaseUrl
|
|
60
|
+
|
|
61
|
+
❌ this.configService.get<string>('DATABASE_URL')!
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Reporting a bad value:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
✅ DATABASE_URL is not a valid URL
|
|
68
|
+
❌ DATABASE_URL is not a valid URL: postgres://admin:hunter2@prod-db/app
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Reaching the environment:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
✅ // only inside the environment module
|
|
75
|
+
❌ const url = process.env.DATABASE_URL
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Enforcement
|
|
79
|
+
|
|
80
|
+
**Boot.** The application does not start with an invalid environment, so a missing variable cannot
|
|
81
|
+
reach a request.
|
|
82
|
+
|
|
83
|
+
**Review only.** That `.env.example` gained the variable someone added to the config class, and that
|
|
84
|
+
no error message quotes a value. A drifted example is caught the next time somebody clones, because
|
|
85
|
+
boot lists everything missing.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Errors
|
|
2
|
+
|
|
3
|
+
## Rule
|
|
4
|
+
|
|
5
|
+
Throw Nest's built-in exceptions: `NotFoundException`, `ForbiddenException`, `BadRequestException`
|
|
6
|
+
and the rest. Do not define an exception hierarchy of your own, and do not construct a problem
|
|
7
|
+
document anywhere but in the filter.
|
|
8
|
+
|
|
9
|
+
Register one global exception filter, declared with a bare `@Catch()` so it receives everything.
|
|
10
|
+
|
|
11
|
+
Respond with `application/problem+json` per RFC 9457: `type`, `title`, `status`, `detail`, `instance`.
|
|
12
|
+
|
|
13
|
+
Carry validation failures in an `errors` extension member, keyed by field name. Use a dotted key for a
|
|
14
|
+
nested field. Produce it from the `ValidationPipe`'s `exceptionFactory`.
|
|
15
|
+
|
|
16
|
+
Give an unhandled exception a generic 500 problem document carrying nothing from the exception, in
|
|
17
|
+
every environment. Send the exception itself to the log and to Sentry.
|
|
18
|
+
|
|
19
|
+
Generate a request id at the start of every request, propagate it with `AsyncLocalStorage`, return it
|
|
20
|
+
in an `X-Request-Id` header on every response, and include it in every problem document. Reuse an
|
|
21
|
+
inbound `X-Request-Id` rather than replacing it.
|
|
22
|
+
|
|
23
|
+
## Rationale
|
|
24
|
+
|
|
25
|
+
Nest's own error bodies are not consistent: an `HttpException` produces `{ statusCode, message }` with
|
|
26
|
+
`message` a string, while the `ValidationPipe` produces `{ statusCode, error, message }` with `message`
|
|
27
|
+
an array. The field set and the type of a field both change, and every client that displays an error
|
|
28
|
+
pays for it. Fixing that already requires a filter, and once a filter is being written a documented
|
|
29
|
+
standard costs exactly what a bespoke shape costs, with the difference that clients, libraries and
|
|
30
|
+
assistants already know the standard.
|
|
31
|
+
|
|
32
|
+
The filter catches everything because otherwise the least predictable failures get the least
|
|
33
|
+
predictable shape. A 500 says nothing because an unhandled exception's message carries whatever was
|
|
34
|
+
nearby: a file path, a fragment of SQL, sometimes a connection string. It says the same thing in every
|
|
35
|
+
environment because the error path is the least tested code in the system and does not need two
|
|
36
|
+
versions of itself.
|
|
37
|
+
|
|
38
|
+
That silence is only supportable because the request id makes it findable. The id covers the whole
|
|
39
|
+
request rather than the moment of failure, since most investigations begin with what the request did
|
|
40
|
+
before it threw.
|
|
41
|
+
|
|
42
|
+
## Applies to
|
|
43
|
+
|
|
44
|
+
Every route. The filter, the `exceptionFactory` and the request-id middleware are registered once at
|
|
45
|
+
bootstrap.
|
|
46
|
+
|
|
47
|
+
## Examples
|
|
48
|
+
|
|
49
|
+
A validation failure:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
✅ { "type": "about:blank", "title": "Bad Request", "status": 400,
|
|
53
|
+
"errors": { "email": ["must be an email"] },
|
|
54
|
+
"requestId": "01J..." }
|
|
55
|
+
|
|
56
|
+
❌ { "statusCode": 400, "error": "Bad Request",
|
|
57
|
+
"message": ["email must be an email"] }
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Raising an error:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
✅ throw new NotFoundException()
|
|
64
|
+
❌ throw new ProblemDetails({ status: 404, title: 'Not Found' })
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
An unhandled exception:
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
✅ { "type": "about:blank", "title": "Internal Server Error", "status": 500,
|
|
71
|
+
"requestId": "01J..." }
|
|
72
|
+
|
|
73
|
+
❌ { "status": 500, "detail": "null value in column \"tenant_id\" of relation \"orders\"" }
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Enforcement
|
|
77
|
+
|
|
78
|
+
**Boot.** The filter, the `exceptionFactory` and the request-id middleware are registered once, so no
|
|
79
|
+
route can produce a different shape by accident.
|
|
80
|
+
|
|
81
|
+
**Review only.** That handlers throw Nest's exceptions rather than building responses by hand, and
|
|
82
|
+
that no `detail` written by hand quotes something internal. Nothing checks either.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Logging
|
|
2
|
+
|
|
3
|
+
## Rule
|
|
4
|
+
|
|
5
|
+
Log through `nestjs-pino`. Do not use Nest's default logger directly and do not add a second logging
|
|
6
|
+
library.
|
|
7
|
+
|
|
8
|
+
Write JSON to stdout. Render it with `pino-pretty` in development only, as a dev dependency. Never
|
|
9
|
+
write a log file.
|
|
10
|
+
|
|
11
|
+
Keep automatic request logging on. Put the request id on every line, taken from `nestjs-pino`'s
|
|
12
|
+
per-request context, the same id returned in `X-Request-Id`. Exclude the health route by name, and
|
|
13
|
+
the documentation route outside production.
|
|
14
|
+
|
|
15
|
+
Never log a request or response body. Configure pino's `redact` for `authorization`, `cookie`,
|
|
16
|
+
`set-cookie`, `password`, `token` and `secret`.
|
|
17
|
+
|
|
18
|
+
Set the level from `LOG_LEVEL`, defaulting to `info`:
|
|
19
|
+
|
|
20
|
+
- `error`: it broke and somebody has to look
|
|
21
|
+
- `warn`: it degraded and continued: a retry, a fallback, a limit reached
|
|
22
|
+
- `info`: an event that matters to the business
|
|
23
|
+
- `debug`: off in production
|
|
24
|
+
|
|
25
|
+
A handled 4xx is not an error. Log it at `info`.
|
|
26
|
+
|
|
27
|
+
## Rationale
|
|
28
|
+
|
|
29
|
+
pino emits structured JSON natively, which is what an aggregator expects, and Nest's default logger
|
|
30
|
+
prints prose for a human terminal. It also carries per-request context, so the request id needs no
|
|
31
|
+
second storage mechanism alongside it.
|
|
32
|
+
|
|
33
|
+
Pretty-printing in development changes only how bytes are rendered for a person: the fields, values
|
|
34
|
+
and structure are identical, and nothing in the system depends on the output format. That is why this
|
|
35
|
+
environment difference is allowed where the error-response one is not; there, the content sent to a
|
|
36
|
+
client changed.
|
|
37
|
+
|
|
38
|
+
Automatic logging exists because *did this request even arrive* is the first question in half of all
|
|
39
|
+
investigations, and without it a successful request leaves no trace. Health checks are excluded
|
|
40
|
+
because a probe every five seconds is seventeen thousand lines a day saying nothing is wrong, and that
|
|
41
|
+
drowns search results and costs money at ingestion.
|
|
42
|
+
|
|
43
|
+
The two secrecy rules cover different holes: bodies are the largest surface, and the automatic request
|
|
44
|
+
log touches headers, where the session cookie lives.
|
|
45
|
+
|
|
46
|
+
Levels carry a written rule because the names look self-explanatory and are not. When a 400 from a
|
|
47
|
+
careless client is logged as `error`, the service's error rate stops measuring health, the alert fires
|
|
48
|
+
constantly, and someone silences the alert that mattered.
|
|
49
|
+
|
|
50
|
+
## Applies to
|
|
51
|
+
|
|
52
|
+
Every module. The logger, redaction and route exclusions are configured once at bootstrap.
|
|
53
|
+
|
|
54
|
+
## Examples
|
|
55
|
+
|
|
56
|
+
Choosing a level:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
✅ warn payment retried after gateway timeout
|
|
60
|
+
❌ error validation failed: email must be an email
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Logging an object:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
✅ logger.info({ userId: user.id }, 'subscription activated')
|
|
67
|
+
❌ logger.info({ user }, 'subscription activated') // whatever user carries goes too
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Enforcement
|
|
71
|
+
|
|
72
|
+
**Configuration.** Redaction and the route exclusions are set once and cannot be bypassed per call
|
|
73
|
+
site.
|
|
74
|
+
|
|
75
|
+
**Review only.** That no one logs a whole object whose contents are unknown; `redact` covers named
|
|
76
|
+
paths, so `{ user }` with a token inside defeats it entirely. And that a 4xx is not raised to `error`.
|
|
77
|
+
|
|
78
|
+
Access to the logs is a security boundary: an unhandled exception is sent there in full, so stack
|
|
79
|
+
traces, SQL fragments and parameter values are legitimately present.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Modules
|
|
2
|
+
|
|
3
|
+
## Rule
|
|
4
|
+
|
|
5
|
+
Create one module per resource: `users`, `orders`, `invoices`.
|
|
6
|
+
|
|
7
|
+
A module contains exactly what `nest g resource` produces: `*.module.ts`, `*.controller.ts`,
|
|
8
|
+
`*.service.ts`, `dto/`, `entities/` and the co-located spec files. Add nothing to that set, **except further
|
|
9
|
+
services**.
|
|
10
|
+
|
|
11
|
+
Add a service to a module when a group of its methods has a name of its own that the resource's name does
|
|
12
|
+
not cover: `order-refunds`, `order-shipping`. Generate it with `nest g service <name> <module> --flat`, beside
|
|
13
|
+
the resource's service. Do not split by line count, and do not create a `services/` directory or one service
|
|
14
|
+
per action.
|
|
15
|
+
|
|
16
|
+
Two services in one module never inject each other in both directions.
|
|
17
|
+
|
|
18
|
+
Export the service. Mark every method that serves only its own module `private`.
|
|
19
|
+
|
|
20
|
+
Reach another module through `imports: [OtherModule]` and inject what it exports. Reference another
|
|
21
|
+
module's entity when a relation requires it.
|
|
22
|
+
|
|
23
|
+
Two modules never depend on each other. When they appear to, move the common part into a third
|
|
24
|
+
module. Do not use `forwardRef`.
|
|
25
|
+
|
|
26
|
+
Do not use barrel files inside a module.
|
|
27
|
+
|
|
28
|
+
Anything serving more than one module becomes its own module, named for what it does:
|
|
29
|
+
`notifications`, `pdf`, `storage`. There is no `SharedModule`.
|
|
30
|
+
|
|
31
|
+
A pure function is a function in a file, imported directly. It gets no module.
|
|
32
|
+
|
|
33
|
+
## Rationale
|
|
34
|
+
|
|
35
|
+
Nest supplies the mechanism and decides none of this, so every rule above exists to remove a choice
|
|
36
|
+
that would otherwise be made differently in each module and by each assistant.
|
|
37
|
+
|
|
38
|
+
One module per resource is the shape `nest g resource` generates and the shape every Nest example
|
|
39
|
+
already has, so the generator and the convention never disagree and a generated module needs no
|
|
40
|
+
correction afterwards. Designing an anatomy instead is what previously cost thirteen files per
|
|
41
|
+
feature, five of which carried no guarantee.
|
|
42
|
+
|
|
43
|
+
`private` makes the compiler enforce the module's surface, at no cost. A cycle is refused because
|
|
44
|
+
Nest's own guidance is to avoid one: `forwardRef` is its escape hatch, and its documented caveats
|
|
45
|
+
include an indeterminate order of instantiation, the class of failure that appears once in
|
|
46
|
+
production and does not reproduce. Barrel files are refused because Nest names them as a way to
|
|
47
|
+
create a cycle nobody can see.
|
|
48
|
+
|
|
49
|
+
**The extra service exists for the module that is large and still one thing.** Splitting into another
|
|
50
|
+
module is right when a second responsibility has crept in, and wrong when it has not: two modules sharing the
|
|
51
|
+
same entities and invariants end up wanting to call each other, which is the cycle refused above. Nest's own
|
|
52
|
+
answer is several providers in one module, and `nest g service <name> <module>` both creates the file and
|
|
53
|
+
registers it, so the generator and the convention still agree.
|
|
54
|
+
|
|
55
|
+
The criterion is a name rather than a size because a line limit ages and invites gaming: somebody moves
|
|
56
|
+
arbitrary methods out to pass the number, and the result is worse than the long file. `--flat` keeps the
|
|
57
|
+
anatomy one level deep. One service per action is refused on evidence: it is what produced thirteen files
|
|
58
|
+
per feature.
|
|
59
|
+
|
|
60
|
+
**What this costs:** the split is a judgement, held by review. And a module with several services can form a
|
|
61
|
+
cycle inside itself, which is why the cycle rule now reaches between services as well as between modules.
|
|
62
|
+
|
|
63
|
+
A `SharedModule` has no admission criterion, so it only grows. Requiring a name forces the question
|
|
64
|
+
*what is this*, and a module nobody can name should not exist.
|
|
65
|
+
|
|
66
|
+
## Applies to
|
|
67
|
+
|
|
68
|
+
Every module under `src/`. The rules about reaching across modules apply wherever one module's code
|
|
69
|
+
refers to another's.
|
|
70
|
+
|
|
71
|
+
## Examples
|
|
72
|
+
|
|
73
|
+
Exporting the service, with internals closed:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
✅ export class UsersService {
|
|
77
|
+
findById(id: string) {}
|
|
78
|
+
private normaliseEmail(v: string) {}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
❌ export class UsersService {
|
|
82
|
+
findById(id: string) {}
|
|
83
|
+
normaliseEmail(v: string) {} // reachable from any module that injects this
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Growing a module that is still one thing:
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
✅ orders/orders.service.ts
|
|
91
|
+
orders/order-refunds.service.ts // nest g service order-refunds orders --flat
|
|
92
|
+
❌ orders/services/cancel-order.service.ts
|
|
93
|
+
❌ refunds/refunds.module.ts // needs orders' entities, and orders needs it back
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Reaching another module:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
✅ @Module({ imports: [UsersModule] })
|
|
100
|
+
❌ @Module({ imports: [forwardRef(() => UsersModule)] })
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Importing inside a module:
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
✅ import { UsersService } from './users.service'
|
|
107
|
+
❌ import { UsersService } from './index'
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Enforcement
|
|
111
|
+
|
|
112
|
+
**Lint.** Barrel-file imports inside a module are verifiable and are caught by a lint rule.
|
|
113
|
+
|
|
114
|
+
**Review only.** Everything else. Nothing checks that a module maps to a resource, that an extra service
|
|
115
|
+
has a name of its own rather than an arbitrary slice, that a method that
|
|
116
|
+
should be `private` is, or that a `SharedModule` has not been created; a reviewer does. `private`
|
|
117
|
+
prevents accidental injection; it does not prevent someone widening the method next week to reach it.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Observability
|
|
2
|
+
|
|
3
|
+
## Rule
|
|
4
|
+
|
|
5
|
+
Expose two health routes with `@nestjs/terminus`. Liveness reports that the process is up and checks
|
|
6
|
+
no dependency. Readiness checks that dependencies answer, the database included. Mark both `@Public()`
|
|
7
|
+
and exclude them from request logging.
|
|
8
|
+
|
|
9
|
+
Connect the ORM explicitly at bootstrap, before the application listens.
|
|
10
|
+
|
|
11
|
+
Do not install an error-reporting or tracing service by default. A project has structured logs and
|
|
12
|
+
these two routes, and nothing else, until its owner chooses otherwise.
|
|
13
|
+
|
|
14
|
+
When Sentry is chosen, wire it exactly as follows and not otherwise.
|
|
15
|
+
|
|
16
|
+
Import `instrument.ts` as the first line of the bootstrap, before `NestFactory` and before every other
|
|
17
|
+
import. Register `SentryModule` in the root module. Add `@SentryExceptionCaptured()` to the existing
|
|
18
|
+
global exception filter's `catch()` method. Do not register `SentryGlobalFilter`.
|
|
19
|
+
|
|
20
|
+
Send an event for any 5xx response: an unhandled exception and a deliberately thrown 5xx alike. Never
|
|
21
|
+
send one for a 4xx, and never make a `logger.error` call create one by itself.
|
|
22
|
+
|
|
23
|
+
Attach the request id as a **tag**, and the user's identifier when a session exists. Leave PII sending
|
|
24
|
+
off. Never attach a request body.
|
|
25
|
+
|
|
26
|
+
## Rationale
|
|
27
|
+
|
|
28
|
+
Liveness and readiness answer opposite questions (*should I restart this process* and *should I send
|
|
29
|
+
it traffic*) and a single endpoint forces one answer to both. The failure that causes is routine: the
|
|
30
|
+
endpoint checks the database, the database wobbles, and the orchestrator restarts a healthy process.
|
|
31
|
+
Restarting does not fix a database, and the wobble becomes an outage.
|
|
32
|
+
|
|
33
|
+
**MikroORM 7's `init` no longer connects**, and the readiness check asks whether a connection exists. Without
|
|
34
|
+
the explicit connect, readiness reports the database down until a first query, and an orchestrator sends no
|
|
35
|
+
query to an instance that is not ready. The instance never becomes ready. Connecting at bootstrap also means
|
|
36
|
+
an unreachable database fails the boot instead of a request.
|
|
37
|
+
|
|
38
|
+
Error reporting is not installed because it requires a commercial account. Installing a service that
|
|
39
|
+
needs an account, a DSN and eventually a bill decides something commercial on the owner's behalf,
|
|
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.
|
|
42
|
+
|
|
43
|
+
`Sentry.init` must run before other modules load, because the instrumentation patches them as they are
|
|
44
|
+
imported. Nothing enforces that ordering, which makes it the most fragile line in the project.
|
|
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.
|
|
50
|
+
|
|
51
|
+
**What reaches Sentry leaves your perimeter.** A log stays in infrastructure you control; a third-party
|
|
52
|
+
service does not, and is readable by anyone with access to that project. That is why the bar here is
|
|
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.
|
|
56
|
+
|
|
57
|
+
## Applies to
|
|
58
|
+
|
|
59
|
+
The bootstrap, the health module, and the global exception filter.
|
|
60
|
+
|
|
61
|
+
## Examples
|
|
62
|
+
|
|
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
|
+
```
|
|
70
|
+
|
|
71
|
+
Wiring Sentry into the existing filter:
|
|
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())
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Enforcement
|
|
84
|
+
|
|
85
|
+
**Boot.** The health routes and the filter are registered once.
|
|
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.
|
|
90
|
+
|
|
91
|
+
Also review: that no event carries a body, and that a 4xx never becomes one.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Pagination
|
|
2
|
+
|
|
3
|
+
## Rule
|
|
4
|
+
|
|
5
|
+
Paginate every list endpoint by cursor. The UUID v7 primary key is the cursor.
|
|
6
|
+
|
|
7
|
+
Order by id descending. `after` means *older than this*. Do not accept a sort parameter.
|
|
8
|
+
|
|
9
|
+
Accept two query parameters, declared on a query DTO: `limit`, defaulting to 20 and carrying
|
|
10
|
+
`@Max(100)`, and `after`.
|
|
11
|
+
|
|
12
|
+
Return `{ "data": [...], "nextCursor": "01J..." | null }`. Add nothing else: no `hasMore`, no
|
|
13
|
+
`total`, no nested `meta`.
|
|
14
|
+
|
|
15
|
+
Set `nextCursor` to the id of the last item on the page, or `null` when the page is the last one.
|
|
16
|
+
|
|
17
|
+
## Rationale
|
|
18
|
+
|
|
19
|
+
Offset pagination fails silently. Under concurrent inserts, page 2 repeats an item from page 1 or
|
|
20
|
+
skips one (no error, just a wrong list) and it degrades at depth, because the database counts and
|
|
21
|
+
discards every skipped row. A cursor is stable at any depth, and UUID v7 being time-ordered means the
|
|
22
|
+
id already is one: no extra sort column, no extra index.
|
|
23
|
+
|
|
24
|
+
That choice fixes the order. `WHERE id < :cursor ORDER BY id DESC` only works over one ordering, so a
|
|
25
|
+
sort parameter would break the cursor without erroring. Descending, because that is what a list screen
|
|
26
|
+
shows.
|
|
27
|
+
|
|
28
|
+
`hasMore` is omitted because a null `nextCursor` already says it, and a field derived from another
|
|
29
|
+
field can disagree with it. `total` is omitted because it costs a `COUNT` on every request: the most
|
|
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.
|
|
32
|
+
|
|
33
|
+
The parameters are an ordinary query DTO, so validation already covers them and the maximum is a
|
|
34
|
+
decorator rather than clamping logic. Clamping would return 100 rows to a client that asked for 500
|
|
35
|
+
without saying so, and the client would advance as though it had consumed 500.
|
|
36
|
+
|
|
37
|
+
## Applies to
|
|
38
|
+
|
|
39
|
+
Every endpoint returning a collection. Filtering is not covered here: a filter is a query DTO field
|
|
40
|
+
like any other, and it composes with the cursor without special handling.
|
|
41
|
+
|
|
42
|
+
## Examples
|
|
43
|
+
|
|
44
|
+
The response:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
✅ { "data": [ ... ], "nextCursor": "01JQ8Z..." }
|
|
48
|
+
❌ { "data": [ ... ], "meta": { "page": 2, "total": 3204, "hasMore": true } }
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The query DTO:
|
|
52
|
+
|
|
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...
|
|
62
|
+
❌ GET /api/v1/orders?sort=total&page=3
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Enforcement
|
|
66
|
+
|
|
67
|
+
**Validation.** `limit`'s bounds are enforced by the pipe, and an unknown parameter (`page`, `sort`,
|
|
68
|
+
`offset`) is already rejected because unknown properties are forbidden.
|
|
69
|
+
|
|
70
|
+
**Review only.** That `nextCursor` is the last item's id and is null on the final page, and that a new
|
|
71
|
+
list endpoint paginates at all rather than returning an unbounded array.
|