@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.
Files changed (220) hide show
  1. package/.prumo-templates/api/auth.md +130 -0
  2. package/.prumo-templates/api/config.md +85 -0
  3. package/.prumo-templates/api/errors.md +82 -0
  4. package/.prumo-templates/api/logging.md +79 -0
  5. package/.prumo-templates/api/modules.md +117 -0
  6. package/.prumo-templates/api/observability.md +91 -0
  7. package/.prumo-templates/api/pagination.md +71 -0
  8. package/.prumo-templates/api/routes.md +89 -0
  9. package/.prumo-templates/client/data.md +124 -0
  10. package/.prumo-templates/client/forms.md +88 -0
  11. package/.prumo-templates/core/conventions.md +74 -0
  12. package/.prumo-templates/core/testing.md +100 -0
  13. package/.prumo-templates/core/tooling.md +118 -0
  14. package/.prumo-templates/database/entities.md +97 -0
  15. package/.prumo-templates/database/migrations.md +74 -0
  16. package/.prumo-templates/database/queries.md +79 -0
  17. package/.prumo-templates/database/testing.md +82 -0
  18. package/.prumo-templates/database/transactions.md +68 -0
  19. package/.prumo-templates/mobile/components.md +83 -0
  20. package/.prumo-templates/mobile/config.md +84 -0
  21. package/.prumo-templates/mobile/routing.md +74 -0
  22. package/.prumo-templates/mobile/storage.md +84 -0
  23. package/.prumo-templates/mobile/structure.md +79 -0
  24. package/.prumo-templates/monorepo/dependencies.md +84 -0
  25. package/.prumo-templates/monorepo/layout.md +89 -0
  26. package/.prumo-templates/monorepo/tasks.md +76 -0
  27. package/.prumo-templates/multi-tenancy/client.md +83 -0
  28. package/.prumo-templates/multi-tenancy/isolation.md +104 -0
  29. package/.prumo-templates/site/rendering.md +83 -0
  30. package/.prumo-templates/site/structure.md +68 -0
  31. package/.prumo-templates/web/components.md +106 -0
  32. package/.prumo-templates/web/routing.md +82 -0
  33. package/.prumo-templates/web/structure.md +92 -0
  34. package/LICENSE +21 -0
  35. package/README.md +110 -0
  36. package/dist/asker.js +28 -0
  37. package/dist/cli.js +62 -0
  38. package/dist/compose.js +222 -0
  39. package/dist/context.js +38 -0
  40. package/dist/generate.js +60 -0
  41. package/dist/jsonc.js +76 -0
  42. package/dist/names.js +10 -0
  43. package/dist/questions.js +63 -0
  44. package/package.json +55 -0
  45. package/templates/api/.env.example +12 -0
  46. package/templates/api/.githooks/install.mjs +18 -0
  47. package/templates/api/.githooks/pre-commit +2 -0
  48. package/templates/api/.githooks/pre-push +2 -0
  49. package/templates/api/README.md +35 -0
  50. package/templates/api/biome.jsonc +18 -0
  51. package/templates/api/docker/init/01-auth-schema.sql +1 -0
  52. package/templates/api/docker-compose.yml +20 -0
  53. package/templates/api/gitignore +5 -0
  54. package/templates/api/migrations/Migration20260914123239_create_profile.ts +13 -0
  55. package/templates/api/nest-cli.json +10 -0
  56. package/templates/api/package.json +69 -0
  57. package/templates/api/pnpm-workspace.yaml +9 -0
  58. package/templates/api/src/app.module.ts +46 -0
  59. package/templates/api/src/auth/auth.constants.ts +2 -0
  60. package/templates/api/src/auth/auth.controller.ts +19 -0
  61. package/templates/api/src/auth/auth.factory.spec.ts +29 -0
  62. package/templates/api/src/auth/auth.factory.ts +52 -0
  63. package/templates/api/src/auth/auth.guard.ts +43 -0
  64. package/templates/api/src/auth/auth.module.ts +30 -0
  65. package/templates/api/src/auth/auth.ts +4 -0
  66. package/templates/api/src/auth/current-user.decorator.ts +9 -0
  67. package/templates/api/src/auth/express.d.ts +10 -0
  68. package/templates/api/src/auth/public.decorator.ts +4 -0
  69. package/templates/api/src/config/env.module.ts +11 -0
  70. package/templates/api/src/config/env.ts +81 -0
  71. package/templates/api/src/errors/problem-details.filter.ts +86 -0
  72. package/templates/api/src/errors/validation-exception.factory.ts +30 -0
  73. package/templates/api/src/health/health.controller.ts +32 -0
  74. package/templates/api/src/health/health.module.ts +9 -0
  75. package/templates/api/src/main.ts +43 -0
  76. package/templates/api/src/mikro-orm.config.ts +4 -0
  77. package/templates/api/src/mikro-orm.factory.ts +14 -0
  78. package/templates/api/src/request-context/request-context.module.ts +9 -0
  79. package/templates/api/src/request-context/request-context.service.ts +17 -0
  80. package/templates/api/src/request-context/request-id.middleware.ts +19 -0
  81. package/templates/api/src/users/dto/update-profile.dto.ts +20 -0
  82. package/templates/api/src/users/entities/profile.entity.ts +27 -0
  83. package/templates/api/src/users/users.controller.ts +23 -0
  84. package/templates/api/src/users/users.module.ts +10 -0
  85. package/templates/api/src/users/users.service.spec.ts +44 -0
  86. package/templates/api/src/users/users.service.ts +36 -0
  87. package/templates/api/test/factories/profile.factory.ts +26 -0
  88. package/templates/api/test/factories/user.factory.ts +21 -0
  89. package/templates/api/test/global-setup.ts +20 -0
  90. package/templates/api/test/setup.ts +80 -0
  91. package/templates/api/tsconfig.build.json +8 -0
  92. package/templates/api/tsconfig.json +29 -0
  93. package/templates/api/vitest.config.mts +17 -0
  94. package/templates/mobile/.env.example +1 -0
  95. package/templates/mobile/.githooks/install.mjs +18 -0
  96. package/templates/mobile/.githooks/pre-commit +2 -0
  97. package/templates/mobile/.githooks/pre-push +2 -0
  98. package/templates/mobile/README.md +30 -0
  99. package/templates/mobile/app.config.ts +13 -0
  100. package/templates/mobile/app.json +39 -0
  101. package/templates/mobile/assets/expo.icon/Assets/expo-symbol 2.svg +3 -0
  102. package/templates/mobile/assets/expo.icon/Assets/grid.png +0 -0
  103. package/templates/mobile/assets/expo.icon/icon.json +35 -0
  104. package/templates/mobile/assets/images/android-icon-background.png +0 -0
  105. package/templates/mobile/assets/images/android-icon-foreground.png +0 -0
  106. package/templates/mobile/assets/images/android-icon-monochrome.png +0 -0
  107. package/templates/mobile/assets/images/icon.png +0 -0
  108. package/templates/mobile/assets/images/splash-icon.png +0 -0
  109. package/templates/mobile/babel.config.js +6 -0
  110. package/templates/mobile/biome.jsonc +21 -0
  111. package/templates/mobile/gitignore +11 -0
  112. package/templates/mobile/global.d.ts +1 -0
  113. package/templates/mobile/jest.config.js +21 -0
  114. package/templates/mobile/metro.config.js +4 -0
  115. package/templates/mobile/nativewind-env.d.ts +1 -0
  116. package/templates/mobile/package.json +63 -0
  117. package/templates/mobile/src/api-contract/api-error.ts +51 -0
  118. package/templates/mobile/src/api-contract/client.ts +26 -0
  119. package/templates/mobile/src/api-contract/index.ts +10 -0
  120. package/templates/mobile/src/api-contract/profile.ts +27 -0
  121. package/templates/mobile/src/app/(app)/_layout.tsx +18 -0
  122. package/templates/mobile/src/app/(app)/index.tsx +18 -0
  123. package/templates/mobile/src/app/(auth)/sign-in.tsx +24 -0
  124. package/templates/mobile/src/app/(auth)/sign-up.tsx +23 -0
  125. package/templates/mobile/src/app/_layout.tsx +54 -0
  126. package/templates/mobile/src/components/ui/button.tsx +36 -0
  127. package/templates/mobile/src/components/ui/field.tsx +30 -0
  128. package/templates/mobile/src/config.ts +3 -0
  129. package/templates/mobile/src/features/auth/auth-client.ts +27 -0
  130. package/templates/mobile/src/features/auth/intended-route.ts +11 -0
  131. package/templates/mobile/src/features/auth/session.ts +17 -0
  132. package/templates/mobile/src/features/auth/sign-in-form.spec.tsx +37 -0
  133. package/templates/mobile/src/features/auth/sign-in-form.tsx +80 -0
  134. package/templates/mobile/src/features/auth/sign-up-form.tsx +99 -0
  135. package/templates/mobile/src/features/clients/clients-context.tsx +21 -0
  136. package/templates/mobile/src/features/forms/form-error.tsx +16 -0
  137. package/templates/mobile/src/features/forms/server-errors.ts +33 -0
  138. package/templates/mobile/src/features/profile/profile-form.spec.tsx +58 -0
  139. package/templates/mobile/src/features/profile/profile-form.tsx +96 -0
  140. package/templates/mobile/src/features/storage/query-persister.ts +15 -0
  141. package/templates/mobile/src/features/storage/storage.ts +11 -0
  142. package/templates/mobile/src/global.css +3 -0
  143. package/templates/mobile/tailwind.config.js +9 -0
  144. package/templates/mobile/test/fake-transport.ts +23 -0
  145. package/templates/mobile/tsconfig.json +21 -0
  146. package/templates/site/.githooks/install.mjs +18 -0
  147. package/templates/site/.githooks/pre-commit +2 -0
  148. package/templates/site/.githooks/pre-push +2 -0
  149. package/templates/site/README.md +28 -0
  150. package/templates/site/biome.jsonc +12 -0
  151. package/templates/site/components.json +25 -0
  152. package/templates/site/gitignore +7 -0
  153. package/templates/site/next.config.ts +7 -0
  154. package/templates/site/package.json +40 -0
  155. package/templates/site/pnpm-workspace.yaml +5 -0
  156. package/templates/site/postcss.config.mjs +7 -0
  157. package/templates/site/src/app/globals.css +129 -0
  158. package/templates/site/src/app/layout.tsx +13 -0
  159. package/templates/site/src/app/page.spec.ts +10 -0
  160. package/templates/site/src/app/page.tsx +17 -0
  161. package/templates/site/src/lib/utils.ts +1 -0
  162. package/templates/site/tsconfig.json +34 -0
  163. package/templates/site/vitest.config.mts +8 -0
  164. package/templates/web/.env.example +1 -0
  165. package/templates/web/.githooks/install.mjs +18 -0
  166. package/templates/web/.githooks/pre-commit +2 -0
  167. package/templates/web/.githooks/pre-push +2 -0
  168. package/templates/web/README.md +29 -0
  169. package/templates/web/biome.jsonc +24 -0
  170. package/templates/web/components.json +25 -0
  171. package/templates/web/gitignore +6 -0
  172. package/templates/web/index.html +12 -0
  173. package/templates/web/package.json +55 -0
  174. package/templates/web/src/api-contract/api-error.ts +51 -0
  175. package/templates/web/src/api-contract/client.ts +26 -0
  176. package/templates/web/src/api-contract/index.ts +10 -0
  177. package/templates/web/src/api-contract/profile.ts +27 -0
  178. package/templates/web/src/app.spec.tsx +12 -0
  179. package/templates/web/src/app.tsx +14 -0
  180. package/templates/web/src/components/ui/button.tsx +57 -0
  181. package/templates/web/src/components/ui/field.tsx +224 -0
  182. package/templates/web/src/components/ui/input.tsx +19 -0
  183. package/templates/web/src/components/ui/label.tsx +17 -0
  184. package/templates/web/src/components/ui/separator.tsx +20 -0
  185. package/templates/web/src/config.ts +7 -0
  186. package/templates/web/src/features/auth/auth-client.ts +12 -0
  187. package/templates/web/src/features/auth/session.ts +17 -0
  188. package/templates/web/src/features/auth/sign-in-form.spec.tsx +30 -0
  189. package/templates/web/src/features/auth/sign-in-form.tsx +73 -0
  190. package/templates/web/src/features/auth/sign-up-form.tsx +79 -0
  191. package/templates/web/src/features/forms/form-error.tsx +15 -0
  192. package/templates/web/src/features/forms/server-errors.ts +33 -0
  193. package/templates/web/src/features/profile/profile-form.spec.tsx +58 -0
  194. package/templates/web/src/features/profile/profile-form.tsx +74 -0
  195. package/templates/web/src/index.css +130 -0
  196. package/templates/web/src/lib/utils.ts +1 -0
  197. package/templates/web/src/main.tsx +28 -0
  198. package/templates/web/src/routeTree.gen.ts +125 -0
  199. package/templates/web/src/router.tsx +21 -0
  200. package/templates/web/src/routes/__root.tsx +10 -0
  201. package/templates/web/src/routes/_authenticated/index.tsx +21 -0
  202. package/templates/web/src/routes/_authenticated.tsx +15 -0
  203. package/templates/web/src/routes/sign-in.tsx +20 -0
  204. package/templates/web/src/routes/sign-up.tsx +17 -0
  205. package/templates/web/test/fake-transport.ts +23 -0
  206. package/templates/web/test/render-app.tsx +17 -0
  207. package/templates/web/test/setup.ts +7 -0
  208. package/templates/web/tsconfig.json +21 -0
  209. package/templates/web/vite.config.ts +18 -0
  210. package/templates/workspace/.githooks/install.mjs +18 -0
  211. package/templates/workspace/.githooks/pre-commit +2 -0
  212. package/templates/workspace/.githooks/pre-push +2 -0
  213. package/templates/workspace/README.md +25 -0
  214. package/templates/workspace/biome.jsonc +8 -0
  215. package/templates/workspace/gitignore +5 -0
  216. package/templates/workspace/package.json +17 -0
  217. package/templates/workspace/packages/api-contract/package.json +20 -0
  218. package/templates/workspace/packages/api-contract/tsconfig.json +11 -0
  219. package/templates/workspace/pnpm-workspace.yaml +3 -0
  220. 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.