@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,89 @@
|
|
|
1
|
+
# Routes
|
|
2
|
+
|
|
3
|
+
## Rule
|
|
4
|
+
|
|
5
|
+
Set a global prefix of `api` and enable `VersioningType.URI`. Every route is reachable at
|
|
6
|
+
`/api/v<n>/<resource>`.
|
|
7
|
+
|
|
8
|
+
Name resources in the plural, kebab-case for compound words. Use REST verbs and Nest's default status
|
|
9
|
+
codes.
|
|
10
|
+
|
|
11
|
+
Put input DTOs in `dto/`, named as the generator names them: `CreateUserDto`, `UpdateUserDto`.
|
|
12
|
+
|
|
13
|
+
Configure the global `ValidationPipe` with `whitelist: true`, `forbidNonWhitelisted: true` and
|
|
14
|
+
`transform: true`. Leave `enableImplicitConversion` off; convert a field that needs it with `@Type()`.
|
|
15
|
+
|
|
16
|
+
Return the entity. Serialize it with `ClassSerializerInterceptor`, marking with `@Exclude` every
|
|
17
|
+
property that must not leave. Where the value arrives as a plain object, add
|
|
18
|
+
`@SerializeOptions({ type: Entity })`. Do not write a response DTO per route.
|
|
19
|
+
|
|
20
|
+
A controller receives the request, calls the service and returns. Everything else on it is a
|
|
21
|
+
decorator.
|
|
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.
|
|
25
|
+
|
|
26
|
+
Serve the documentation UI outside production only.
|
|
27
|
+
|
|
28
|
+
## Rationale
|
|
29
|
+
|
|
30
|
+
The global prefix makes Better Auth's `/api/auth/*` fall out of the ordinary convention instead of
|
|
31
|
+
becoming the one route with an exception in its path.
|
|
32
|
+
|
|
33
|
+
Versioning costs five characters today and a coordinated migration of every consumer later, including
|
|
34
|
+
a published mobile app that does not update when you want it to. It goes in the URI rather than a
|
|
35
|
+
header because a version in the URL appears in logs, caches, a `curl` line and a Sentry report:
|
|
36
|
+
exactly where you look when v1 breaks.
|
|
37
|
+
|
|
38
|
+
`forbidNonWhitelisted` is what makes an unexpected property visible. Stripping it silently returns 200
|
|
39
|
+
to a client that sent `role: "admin"`, which looks like success and hides the honest bug and the
|
|
40
|
+
malicious attempt equally.
|
|
41
|
+
|
|
42
|
+
Serialization is declared once on the entity rather than repeated in every response DTO that touches
|
|
43
|
+
it. Three DTOs over one entity would each have to remember to omit `tenant_id`, and so would the
|
|
44
|
+
fourth someone adds later.
|
|
45
|
+
|
|
46
|
+
The swagger plugin reads the class-validator decorators that already exist. Declaring the same field a
|
|
47
|
+
third time by hand produces the copy nobody updates, and API documentation that lies is worse than
|
|
48
|
+
none, because consumers trust it.
|
|
49
|
+
|
|
50
|
+
## Applies to
|
|
51
|
+
|
|
52
|
+
Every controller under `src/`, and the application bootstrap where the prefix, versioning, pipe and
|
|
53
|
+
documentation are configured.
|
|
54
|
+
|
|
55
|
+
## Examples
|
|
56
|
+
|
|
57
|
+
Resource naming and versioning:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
✅ @Controller({ path: 'billing-accounts', version: '1' })
|
|
61
|
+
❌ @Controller('billingAccount')
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Keeping a property out of a response:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
✅ @Exclude() tenantId!: string
|
|
68
|
+
❌ tenantId!: string
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
A controller that delegates:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
✅ findOne(@Param('id') id: string) { return this.users.findById(id) }
|
|
75
|
+
❌ findOne(@Param('id') id: string) {
|
|
76
|
+
const u = await this.users.findById(id)
|
|
77
|
+
if (u.plan === 'trial' && u.createdAt < cutoff) u.plan = 'expired'
|
|
78
|
+
return u
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Enforcement
|
|
83
|
+
|
|
84
|
+
**Boot.** The prefix, versioning, `ValidationPipe` and serializer are configured once at bootstrap, so
|
|
85
|
+
a route cannot opt out of them by accident.
|
|
86
|
+
|
|
87
|
+
**Review only.** Resource naming, controller thinness, and (most importantly) whether a new
|
|
88
|
+
sensitive column carries `@Exclude`. Nothing checks that, and a column added without it is published
|
|
89
|
+
to every client of that route.
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Data
|
|
2
|
+
|
|
3
|
+
## Rule
|
|
4
|
+
|
|
5
|
+
Keep the wire contract with the project's API in `src/api-contract/`: `createClient`, which builds every URL
|
|
6
|
+
including the `/api/v1` prefix, the `ApiError`, the hand-written request and response types, and the query
|
|
7
|
+
factories. **Let the app inject the transport** (`createClient({ baseUrl, fetch })`) with
|
|
8
|
+
`credentials: 'include'` on the web configured there, once. In a monorepo the same directory is
|
|
9
|
+
`packages/api-contract`.
|
|
10
|
+
|
|
11
|
+
Call Better Auth's routes through Better Auth's own client, `createAuthClient`, configured once. In `mobile`, give it
|
|
12
|
+
the `expoClient` plugin, and let the transport for the project's API attach the session with `auth.getCookie()` and
|
|
13
|
+
send `credentials: 'omit'`. Do not write
|
|
14
|
+
its routes, bodies or responses by hand, and do not send them through the wrapper.
|
|
15
|
+
|
|
16
|
+
Declare request and response types by hand, in `api-contract/`. Do not generate a client
|
|
17
|
+
from the OpenAPI document, and **do not `import type` an entity from the API package**; see below.
|
|
18
|
+
|
|
19
|
+
Give every query a factory in `api-contract/`, taking the client and returning the whole options object so the key and the fetch
|
|
20
|
+
function travel together. Include in the key everything that changes the result: filters, cursor,
|
|
21
|
+
ordering.
|
|
22
|
+
|
|
23
|
+
Let the client parse every `application/problem+json` body into one `ApiError` carrying `status`,
|
|
24
|
+
`title`, `detail`, `requestId` and the `errors` map, and throw it. Convert the auth client's `error` into the
|
|
25
|
+
same `ApiError` in one adapter. No component reads a raw error body.
|
|
26
|
+
|
|
27
|
+
Use `useInfiniteQuery` for paginated lists, with `getNextPageParam` returning `nextCursor`, or `undefined`
|
|
28
|
+
once it is null.
|
|
29
|
+
|
|
30
|
+
Invalidate explicitly in each mutation's `onSuccess`, building the key from the same factory. Never
|
|
31
|
+
invalidate everything.
|
|
32
|
+
|
|
33
|
+
## Rationale
|
|
34
|
+
|
|
35
|
+
Types are written by hand because generated ones arrive as deeply nested `paths[…][…][…]` shapes that
|
|
36
|
+
every project then wraps in aliases to make usable. The cost accepted in exchange is real and silent: the
|
|
37
|
+
API renames a field, the client keeps compiling against its own copy, and the failure appears as
|
|
38
|
+
`undefined` on a screen.
|
|
39
|
+
|
|
40
|
+
**Importing the entity type from the API package looks like the obvious fix and is unsound.** Responses
|
|
41
|
+
are entities passed through `ClassSerializerInterceptor`, so every `@Exclude`d property is missing from the
|
|
42
|
+
JSON while remaining on the entity type. The shared type would promise `tenantId` on a payload that never
|
|
43
|
+
carries it: a type that lies, which is worse than the duplication it removes.
|
|
44
|
+
|
|
45
|
+
**The contract lives in one directory, not beside each feature, so that one template serves both
|
|
46
|
+
architectures.** The monorepo form moves `api-contract/` whole into `packages/api-contract` and changes one
|
|
47
|
+
import prefix. Scattered across features, the same code would need gathering at copy time, or a second copy
|
|
48
|
+
of it. It also mirrors the API: every wire shape in one place, so a renamed field has one file to fix. **What it
|
|
49
|
+
costs:** a project that stays `alone` passes its transport as a parameter it never varies.
|
|
50
|
+
|
|
51
|
+
The query factory settles by construction what would otherwise be discipline. Wherever something
|
|
52
|
+
prefetches and something else reads (a route loader on the web, a screen preloading the next one) a
|
|
53
|
+
difference between their keys fills one cache entry while the other is read, paying twice and gaining
|
|
54
|
+
nothing, with no warning. A key that omits a filter is the same class of fault: two screens share one
|
|
55
|
+
entry and the second shows the first's data.
|
|
56
|
+
|
|
57
|
+
**There are two doors because there are two contracts.** The project's API is ours, so its types are written
|
|
58
|
+
here. Better Auth's routes are Better Auth's: hand-writing them would copy a contract we do not control, which
|
|
59
|
+
drifts on every upgrade and grows with every plugin. Its client already carries those types and follows the
|
|
60
|
+
library, and `multi-tenancy/client.md` depends on it. **Its errors are not problem+json**: `toNodeHandler`
|
|
61
|
+
writes the response itself, past the exception filter, so the body is `{ message, code }` with no `requestId`.
|
|
62
|
+
The adapter is what keeps that difference out of every form.
|
|
63
|
+
|
|
64
|
+
One `ApiError` keeps the shape of problem+json in a single file instead of in every component that
|
|
65
|
+
displays an error. It also puts `requestId` on every failure without anyone asking, which is what makes a
|
|
66
|
+
500 that reveals nothing supportable: the user opening a ticket arrives with the identifier that finds the
|
|
67
|
+
log line.
|
|
68
|
+
|
|
69
|
+
Invalidating everything is never wrong and makes a screen with six queries fire six requests on any save,
|
|
70
|
+
which reads as an application that is slow for no reason.
|
|
71
|
+
|
|
72
|
+
## Applies to
|
|
73
|
+
|
|
74
|
+
Every client (`web`, `mobile` and `site`) for every call to the API.
|
|
75
|
+
|
|
76
|
+
The TanStack Query rules apply where Query is used, which is `web` and `mobile`. A `site` fetching on the
|
|
77
|
+
server uses the wrapper, the hand-written types and the `ApiError`, and not the query factory or
|
|
78
|
+
`useInfiniteQuery`.
|
|
79
|
+
|
|
80
|
+
## Examples
|
|
81
|
+
|
|
82
|
+
The query factory:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
✅ export const ordersQuery = (filters) => ({ queryKey: ['orders', filters], queryFn: … })
|
|
86
|
+
// every caller builds the key the same way
|
|
87
|
+
❌ prefetch: queryKey: ['orders']
|
|
88
|
+
component: queryKey: ['orders', filters]
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Reaching the server:
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
✅ api.get('/users/me') authClient.signIn.email({ email, password })
|
|
95
|
+
❌ api.post('/auth/sign-in/email', { … }) // Better Auth's contract, copied by hand
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Reading an error:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
✅ catch (e: ApiError) { form.setErrors(e.errors); toast(e.title, e.requestId) }
|
|
102
|
+
❌ catch (e) { const body = await e.response.json(); body['errors'] }
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Invalidating:
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
✅ onSuccess: () => qc.invalidateQueries({ queryKey: ordersQuery().queryKey })
|
|
109
|
+
❌ onSuccess: () => qc.invalidateQueries()
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Enforcement
|
|
113
|
+
|
|
114
|
+
**Structural.** The key and the fetch function cannot disagree, because the factory returns both. The
|
|
115
|
+
error shape is parsed in one place, so no component can depend on the wire format.
|
|
116
|
+
|
|
117
|
+
**Review only, and two of these fail silently:**
|
|
118
|
+
|
|
119
|
+
That a hand-written type still matches what the API returns. Nothing checks it, and the divergence appears
|
|
120
|
+
as `undefined` at runtime. This is the known cost of not generating the client.
|
|
121
|
+
|
|
122
|
+
That a query key includes every filter that changes the result.
|
|
123
|
+
|
|
124
|
+
That a mutation touching two resources invalidates both. Forgetting one leaves that screen stale.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Forms
|
|
2
|
+
|
|
3
|
+
## Rule
|
|
4
|
+
|
|
5
|
+
Keep a form's Zod schema in its feature, beside the component. Derive the request type from it with
|
|
6
|
+
`z.infer` where the shapes match. Where they differ, let the schema describe the **form** and write an
|
|
7
|
+
explicit function that builds the request.
|
|
8
|
+
|
|
9
|
+
Configure `useForm` with `mode: 'onTouched'` and `reValidateMode: 'onChange'`.
|
|
10
|
+
|
|
11
|
+
On a failed submission, walk the `errors` map from the `ApiError` and call `setError` on each matching
|
|
12
|
+
field. **Turn anything that matches no field into a form-level error**, shown at the top with the
|
|
13
|
+
`requestId`. Do it through one function in `features/forms/`, which sets the form-level error on
|
|
14
|
+
`root.server`.
|
|
15
|
+
|
|
16
|
+
Show an error carrying no field the same way: form-level, with `title` and the `requestId`. **Every
|
|
17
|
+
authentication error is this case**: Better Auth's errors carry no field map.
|
|
18
|
+
|
|
19
|
+
Disable the submit button while `isSubmitting`. Never clear the form on error.
|
|
20
|
+
|
|
21
|
+
## Rationale
|
|
22
|
+
|
|
23
|
+
Client types are written by hand and a form already carries a schema, so declaring both separately would
|
|
24
|
+
be two declarations of one fact. `z.infer` removes the second wherever the shapes agree, and the explicit
|
|
25
|
+
transform keeps the difference visible where they do not.
|
|
26
|
+
|
|
27
|
+
**A form's schema is not the server's validation.** The API validates its own input and trusts no client;
|
|
28
|
+
this schema exists for immediate feedback. When the two disagree, the server wins, which is why unmatched
|
|
29
|
+
errors must surface rather than vanish.
|
|
30
|
+
|
|
31
|
+
That is the hole the mapping rule exists for. The server can reject a field this form does not have,
|
|
32
|
+
because its rules changed or the validation is business rather than shape. Walking only the form's own
|
|
33
|
+
fields makes that disappear: the user presses save, nothing happens, nothing is shown. A button that does
|
|
34
|
+
nothing is the most frustrating failure there is and leaves support nothing to work from.
|
|
35
|
+
|
|
36
|
+
One function rather than a loop in each form, because the loop is where the silent failure lives, and three
|
|
37
|
+
copies of it diverge. `root.server` is react-hook-form's place for an error belonging to no field, so it is not
|
|
38
|
+
cleared when a field revalidates.
|
|
39
|
+
|
|
40
|
+
`onTouched` separates two moments that look like one. Before a field has errored the user is not
|
|
41
|
+
interrupted; after it has, they are correcting and want to know when it is right. `onChange` alone turns
|
|
42
|
+
a field red while someone is still typing; `onSubmit` alone reports the third field's problem after twelve
|
|
43
|
+
have been filled.
|
|
44
|
+
|
|
45
|
+
Disabling the button prevents the most common cause of duplicate records. Preserving the form on error
|
|
46
|
+
prevents losing twelve fields of typing over a problem that was not the user's.
|
|
47
|
+
|
|
48
|
+
## Applies to
|
|
49
|
+
|
|
50
|
+
Every form, in every client.
|
|
51
|
+
|
|
52
|
+
## Examples
|
|
53
|
+
|
|
54
|
+
Schema as the source of the type:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
✅ const schema = z.object({ email: z.email() })
|
|
58
|
+
type CreateUser = z.infer<typeof schema>
|
|
59
|
+
❌ const schema = z.object({ email: z.email() })
|
|
60
|
+
type CreateUser = { email: string }
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Mapping the response:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
✅ for (const [field, messages] of Object.entries(err.errors))
|
|
67
|
+
isKnown(field) ? setError(field, …) : addFormError(messages)
|
|
68
|
+
❌ for (const field of Object.keys(form.getValues()))
|
|
69
|
+
if (err.errors[field]) setError(field, …) // unknown fields vanish
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Submission:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
✅ <button disabled={isSubmitting}>
|
|
76
|
+
❌ <button> // a double click creates two records
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Enforcement
|
|
80
|
+
|
|
81
|
+
**The form library.** `isSubmitting` and the validation modes are configuration, not code that can drift
|
|
82
|
+
per form.
|
|
83
|
+
|
|
84
|
+
**A test, where one exists.** Rendering the form against a fake transport that returns `400` with an
|
|
85
|
+
`errors` map, and asserting the message appears **inside that field**, fails the moment the mapping breaks.
|
|
86
|
+
|
|
87
|
+
**Review only.** That unmatched field errors are surfaced rather than dropped; the failure is silent and
|
|
88
|
+
looks like a button that does nothing. And that a form is not reset in an error branch.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Conventions
|
|
2
|
+
|
|
3
|
+
## Rule
|
|
4
|
+
|
|
5
|
+
Name every source file in kebab-case, with the role suffix the generator uses: `users.service.ts`,
|
|
6
|
+
`users.controller.ts`, `create-user.dto.ts`, `button.tsx`.
|
|
7
|
+
|
|
8
|
+
Write a comment only when it carries a fact the code cannot carry about itself. Before writing one, ask
|
|
9
|
+
whether a careful reader could recover it anyway. If they could, delete it.
|
|
10
|
+
|
|
11
|
+
Those three places are where the answer is no:
|
|
12
|
+
|
|
13
|
+
- an outside constraint: *this vendor returns 200 with the error in the body*
|
|
14
|
+
- code that looks wrong and is right
|
|
15
|
+
- something tried that does not work
|
|
16
|
+
|
|
17
|
+
Do not comment what the code says. Do not leave commented-out code. Do not write a file header, an author
|
|
18
|
+
tag or a change log; git holds all three.
|
|
19
|
+
|
|
20
|
+
## Rationale
|
|
21
|
+
|
|
22
|
+
kebab-case is what `nest g resource` and shadcn's CLI both emit, so anything else adds a rename step after
|
|
23
|
+
every generation, and a correction after generation is what people forget. It also never touches a problem
|
|
24
|
+
PascalCase lives with: macOS and Windows are case-insensitive, so renaming `OrderList.tsx` to
|
|
25
|
+
`orderList.tsx` does not register as a change in git, and the file ends up cased differently in the
|
|
26
|
+
repository and on a Linux clone.
|
|
27
|
+
|
|
28
|
+
The comment test exists because *comment the why, not the what* decides nothing in the moment. Every
|
|
29
|
+
comment looks like a why to whoever is writing it, and
|
|
30
|
+
`// increment the counter because we need a count` is a why and is rubbish. *Could a careful reader recover
|
|
31
|
+
this?* can be applied by looking at the line, which makes it a ruler rather than a principle.
|
|
32
|
+
|
|
33
|
+
The three cases are not categories to sort into. They are simply where the test answers no. An outside
|
|
34
|
+
constraint is not in the code at all. Code that looks wrong and is right is the only place a comment stops
|
|
35
|
+
somebody "fixing" it. And something tried that does not work is the information most often lost and most
|
|
36
|
+
expensive to rediscover.
|
|
37
|
+
|
|
38
|
+
## Applies to
|
|
39
|
+
|
|
40
|
+
Every source file, in every type.
|
|
41
|
+
|
|
42
|
+
## Examples
|
|
43
|
+
|
|
44
|
+
Naming:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
✅ create-user.dto.ts users.service.ts order-list.tsx
|
|
48
|
+
❌ CreateUserDto.ts UsersService.ts OrderList.tsx
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
A comment that earns its place:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
✅ // The gateway returns 200 with the failure in the body; status is not enough.
|
|
55
|
+
if (body.status !== 'ok') throw new BadGatewayException()
|
|
56
|
+
|
|
57
|
+
❌ // check if the status is not ok and throw
|
|
58
|
+
if (body.status !== 'ok') throw new BadGatewayException()
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Something tried that does not work:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
✅ // Batching these breaks ordering: the gateway processes out of sequence.
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Enforcement
|
|
68
|
+
|
|
69
|
+
**Review only.** No check can judge whether a sentence carries a fact. Counting comments would be satisfied
|
|
70
|
+
by deleting the wrong ones, and a ratio is satisfied the same way.
|
|
71
|
+
|
|
72
|
+
**What stands in for enforcement is the code around you.** An assistant (and a new colleague) imitates the
|
|
73
|
+
code it reads before it obeys a document. If the files in this project carry comments that fail the test,
|
|
74
|
+
the rule is already dead, whatever this document says.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
|
|
3
|
+
## Rule
|
|
4
|
+
|
|
5
|
+
Run tests with Vitest, **except in `mobile`, which runs Jest with the `jest-expo` preset.** Put a test beside the
|
|
6
|
+
file it tests, named `*.spec.ts`.
|
|
7
|
+
|
|
8
|
+
**Do not mock the database.** Test code that touches it against the real one. Write a test without a
|
|
9
|
+
database only for code without a database.
|
|
10
|
+
|
|
11
|
+
Do not mock `EntityManager`, a repository, or any concrete class from a library.
|
|
12
|
+
|
|
13
|
+
**In a client, replace the network through the injected transport**: pass a fake `fetch` to `createClient`.
|
|
14
|
+
Do not add a request-mocking library. Test a component that shows an error by rendering it and asserting
|
|
15
|
+
**where** the error appears.
|
|
16
|
+
|
|
17
|
+
Measure coverage and report it. Do not block a merge on a coverage threshold.
|
|
18
|
+
|
|
19
|
+
Assert behaviour, not execution. A test that calls a function without asserting anything is not a test.
|
|
20
|
+
|
|
21
|
+
## Rationale
|
|
22
|
+
|
|
23
|
+
A mirrored test tree has to be moved when the code moves, and nobody moves it, so the old path survives
|
|
24
|
+
as an orphan that still passes. Co-located, a test travels with its file without anyone thinking about it.
|
|
25
|
+
`.spec.ts` follows the generator, so no rename step is needed after generating a module.
|
|
26
|
+
|
|
27
|
+
**There are no interfaces to mock.** The architecture has no ports, so a service talks to MikroORM's
|
|
28
|
+
concrete `EntityManager`. Mocking it means imitating identity map, unit of work and flush: the hardest
|
|
29
|
+
thing in a suite to write and the least trustworthy. Each time the imitation is wrong, the test passes and
|
|
30
|
+
production does not, and **a test that passes for the wrong reason is worse than no test, because it grants
|
|
31
|
+
permission not to look.**
|
|
32
|
+
|
|
33
|
+
A real Postgres is already provided, one per worker, with its schema built from the migrations. That also
|
|
34
|
+
dissolves the *unit or integration* argument every project has and nobody wins: the answer is mechanical;
|
|
35
|
+
the code touches the database or it does not.
|
|
36
|
+
|
|
37
|
+
A client has no database, and a `web` generated `alone` has no API beside it to test against. The transport is
|
|
38
|
+
already a parameter (the integration layer takes it from the app) so a fake `fetch` needs no library and
|
|
39
|
+
imitates nothing internal. **What it costs:** the fake responses are written by hand and can drift from what the
|
|
40
|
+
API really returns, unnoticed. That is the same cost already accepted for hand-written contract types. The rules
|
|
41
|
+
worth testing in a client are the silent ones: an `errors` map reaching the right field, and an authentication
|
|
42
|
+
error becoming a form-level message.
|
|
43
|
+
|
|
44
|
+
**`mobile` is the one exception, and only the runner changes.** Expo's testing guide installs Jest with `jest-expo`,
|
|
45
|
+
`@testing-library/react-native` peers `jest >=29`, and the community Vitest integration for React Native has not
|
|
46
|
+
been published since January 2024. Every other rule here (placement, no database mocks, no threshold, asserting
|
|
47
|
+
behaviour) holds unchanged. **What it costs:** two runners in one workspace with near-identical APIs, so `vi.fn`
|
|
48
|
+
written in a mobile test is a mistake an assistant will make.
|
|
49
|
+
|
|
50
|
+
**Three things a mobile test meets, verified with Expo SDK 57:** `render` from `@testing-library/react-native` 14
|
|
51
|
+
is **asynchronous** and its matchers need no setup. `jest-expo` leaves the app manifest empty, which Better Auth's
|
|
52
|
+
Expo plugin needs to build an origin, so a component test uses a Better Auth client without that plugin, which is
|
|
53
|
+
why a form asks only for the auth methods it calls. And Better Auth ships ESM, partly as `.mjs`, so Jest extends
|
|
54
|
+
`jest-expo`'s own transform settings to include it rather than restating them.
|
|
55
|
+
|
|
56
|
+
A coverage threshold turns a proxy into a target. Whoever is below writes tests to raise the number rather
|
|
57
|
+
than to check anything; whoever is above stops thinking. With no database mocks a test costs more to write,
|
|
58
|
+
which pushes even harder towards the cheap way of raising coverage: the test that asserts nothing.
|
|
59
|
+
Measuring without a threshold still answers the useful question, which is *where is there nothing at all*.
|
|
60
|
+
|
|
61
|
+
## Applies to
|
|
62
|
+
|
|
63
|
+
Every type. Anything specific to running tests against a database (containers, isolation between tests,
|
|
64
|
+
factories) belongs to the database area, not here.
|
|
65
|
+
|
|
66
|
+
## Examples
|
|
67
|
+
|
|
68
|
+
Placement:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
✅ features/orders/order-list.tsx
|
|
72
|
+
features/orders/order-list.spec.tsx
|
|
73
|
+
❌ tests/features/orders/order-list.test.tsx
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
What a service test uses:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
✅ const em = await testEntityManager() // real Postgres
|
|
80
|
+
❌ const em = { findOne: vi.fn(), flush: vi.fn() }
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Asserting:
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
✅ expect(await service.activate(id)).toMatchObject({ status: 'active' })
|
|
87
|
+
❌ await service.activate(id) // covers the line, checks nothing
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Enforcement
|
|
91
|
+
|
|
92
|
+
**Structural.** With no mock of the database available as a convention, a service test has to use the real
|
|
93
|
+
one.
|
|
94
|
+
|
|
95
|
+
**Review only.** That a test asserts something, that a new module arrived with tests at all, and that
|
|
96
|
+
nobody introduced a mock of a library class.
|
|
97
|
+
|
|
98
|
+
**The known gap:** with no threshold, coverage can fall and CI stays green. Review is the only defence, and
|
|
99
|
+
review tires. If a floor is ever added, the honest form is on the diff (new code arrives tested) rather
|
|
100
|
+
than an average the existing code sustains.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Tooling
|
|
2
|
+
|
|
3
|
+
## Rule
|
|
4
|
+
|
|
5
|
+
Base the TypeScript configuration on `nestjs/typescript-starter`: `module` and `moduleResolution` both
|
|
6
|
+
`nodenext`, `target` `ES2023`, `strict` on, `strictPropertyInitialization` off.
|
|
7
|
+
|
|
8
|
+
Add `noUncheckedIndexedAccess`. Do not add `exactOptionalPropertyTypes`.
|
|
9
|
+
|
|
10
|
+
Use `bundler` for `moduleResolution` in a client app (`web`, `mobile`, `site`) where a bundler resolves
|
|
11
|
+
modules.
|
|
12
|
+
|
|
13
|
+
Run Biome's recommended preset, configured in `biome.jsonc`. Write a one-line reason in the configuration
|
|
14
|
+
for any rule added or disabled.
|
|
15
|
+
|
|
16
|
+
In `api`, enable `javascript.parser.unsafeParameterDecoratorsEnabled` and turn `style.useImportType` off.
|
|
17
|
+
|
|
18
|
+
In a client using shadcn, turn `a11y.noLabelWithoutControl` and `a11y.useSemanticElements` off for
|
|
19
|
+
`components/ui/` only, through an override. Keep both on everywhere else.
|
|
20
|
+
|
|
21
|
+
**Never import a class with `import type` where Nest reads its type at runtime**: a constructor
|
|
22
|
+
dependency, or a DTO received through a decorated handler parameter.
|
|
23
|
+
|
|
24
|
+
Run `biome check --staged --write` in `.githooks/pre-commit`. Run `tsc --noEmit` in `.githooks/pre-push`.
|
|
25
|
+
Wire both with `"prepare": "node .githooks/install.mjs"`, which sets `core.hooksPath` inside a Git repository,
|
|
26
|
+
does nothing outside one, and fails when Git is present and the setting cannot be written.
|
|
27
|
+
|
|
28
|
+
## Rationale
|
|
29
|
+
|
|
30
|
+
Following Nest's starter is not deference: it is what keeps the generator and the convention in agreement,
|
|
31
|
+
so a freshly generated project needs no correction afterwards. `nodenext` also happens to be one of the
|
|
32
|
+
three values MikroORM v7 accepts, so the ORM and the framework ask for the same thing.
|
|
33
|
+
|
|
34
|
+
`strictPropertyInitialization` is off because a DTO's properties are filled by validation rather than by a
|
|
35
|
+
constructor, so the flag reports an error about something correct. The cost is real and wider than DTOs:
|
|
36
|
+
with it off, **any** class may declare a property that is never assigned and the compiler stays quiet.
|
|
37
|
+
|
|
38
|
+
`noUncheckedIndexedAccess` is added because `rows[0]` treated as present is the most common source of a
|
|
39
|
+
runtime `undefined`. `exactOptionalPropertyTypes` is left out because it is the one strictness flag whose
|
|
40
|
+
friction lands in library types rather than in your own code.
|
|
41
|
+
|
|
42
|
+
**The hooks are split by cost.** Most of this project's guarantees are compiler guarantees, so a hook that
|
|
43
|
+
only formats lets through the class of error the rules most rely on. But `tsc` takes five to thirty
|
|
44
|
+
seconds, and a slow hook is one somebody bypasses with `--no-verify`, after which Biome does not run
|
|
45
|
+
either. Formatting is instant and belongs on every commit; type checking needs to run before code leaves
|
|
46
|
+
the machine, which is what pre-push is.
|
|
47
|
+
|
|
48
|
+
`biome.jsonc` rather than `biome.json`, because the reason has to live beside the rule and JSON cannot hold
|
|
49
|
+
a comment.
|
|
50
|
+
|
|
51
|
+
**Biome cannot parse Nest without the parser option.** Decorators on constructor and handler parameters
|
|
52
|
+
(`@Inject()`, `@Body()`, `@CurrentUser()`) belong to the legacy proposal, and Biome reports every one as a
|
|
53
|
+
syntax error, not a lint warning, until the option is on.
|
|
54
|
+
|
|
55
|
+
**`useImportType` breaks injection, and nothing reports it.** Nest resolves a dependency from the type the
|
|
56
|
+
compiler emits into the constructor's metadata. `import type` is erased from the output, so the emitted type
|
|
57
|
+
becomes `Function` and the application refuses to boot. The rule's autofix makes that rewrite across the
|
|
58
|
+
codebase in one pass; in this template it did, and lint, typecheck, build and every service test still
|
|
59
|
+
passed. **On a DTO it is worse:** `ValidationPipe` receives `Object`, validates nothing, and raises nothing,
|
|
60
|
+
so `whitelist` and `forbidNonWhitelisted` silently stop existing. Written by hand, the same `import type`
|
|
61
|
+
does the same damage, which is why the rule forbids the import and not only the lint rule.
|
|
62
|
+
|
|
63
|
+
**The shadcn override is scoped because both rules are right in application code.** A generated `Label`
|
|
64
|
+
receives `htmlFor` through props the rule cannot see, and shadcn's `Field` uses `role="group"` deliberately.
|
|
65
|
+
Turning them off project-wide would remove the protection from the screens, where a label without a control is
|
|
66
|
+
a real defect.
|
|
67
|
+
|
|
68
|
+
**The hooks are wired by a script, not by `git config` directly,** because `prepare` runs on every install and
|
|
69
|
+
`git config` exits 128 outside a repository: an install inside a container, or from an archive, failed outright. A
|
|
70
|
+
shell guard was refused: pnpm runs scripts in the system shell, which on Windows is `cmd`, and `|| true` would also
|
|
71
|
+
swallow the real failure of a repository that could not be configured.
|
|
72
|
+
|
|
73
|
+
A short lint configuration is one somebody reads before disagreeing with it. The written reason stops
|
|
74
|
+
nobody from adjusting and guarantees the configuration explains itself a year later.
|
|
75
|
+
|
|
76
|
+
## Applies to
|
|
77
|
+
|
|
78
|
+
Every project, of every type. Client apps differ only in `moduleResolution`.
|
|
79
|
+
|
|
80
|
+
## Examples
|
|
81
|
+
|
|
82
|
+
Configuration changes:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
✅ // disabled: our DTOs are validated at runtime, not constructed
|
|
86
|
+
"noUnusedVariables": "off"
|
|
87
|
+
❌ "noUnusedVariables": "off"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Importing what Nest reads at runtime:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
✅ import { UsersService } from './users.service'
|
|
94
|
+
import { UpdateProfileDto } from './dto/update-profile.dto'
|
|
95
|
+
❌ import type { UsersService } from './users.service' // boot fails: Function
|
|
96
|
+
❌ import type { UpdateProfileDto } from './dto/update-profile.dto' // nothing is validated
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Reading an array:
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
✅ const first = rows[0]; if (!first) return
|
|
103
|
+
❌ const first = rows[0]!
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Enforcement
|
|
107
|
+
|
|
108
|
+
**Compiler.** `strict` and `noUncheckedIndexedAccess` are enforced on every build and by the pre-push hook.
|
|
109
|
+
|
|
110
|
+
**The hooks.** Formatting cannot reach a commit unformatted, and a type error cannot reach the remote,
|
|
111
|
+
unless somebody uses `--no-verify`, or never ran an install and so never got `core.hooksPath` configured.
|
|
112
|
+
|
|
113
|
+
**Boot, partially.** A constructor dependency imported as a type fails at boot. **A DTO imported as a type
|
|
114
|
+
does not fail anywhere**, not at boot and not in a service test, so it is caught only by a request test
|
|
115
|
+
that sends an invalid body, or by review.
|
|
116
|
+
|
|
117
|
+
**Review only.** That a disabled rule carries its reason, and that `strictPropertyInitialization` being
|
|
118
|
+
off did not leave an uninitialised property somewhere outside a DTO.
|