@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,97 @@
1
+ # Entities
2
+
3
+ ## Rule
4
+
5
+ Declare an entity with `defineEntity` **and a class**. Put the schema in the `defineEntity` call and
6
+ no MikroORM decorators on the class. The class carries `@Exclude` and `@ApiProperty` only: the
7
+ serialization and documentation decorators.
8
+
9
+ Keep the default `UnderscoreNamingStrategy`. Table names are singular and match the class name;
10
+ columns are snake_case. Do not configure a naming strategy and do not set `tableName`.
11
+
12
+ Give every table `id`, `created_at` and `updated_at`. Let `id` be `uuid` with `DEFAULT uuidv7()`. Let
13
+ both timestamps be `timestamptz`, maintained by MikroORM's `onCreate` and `onUpdate`.
14
+
15
+ Add `tenant_id` to every tenant-scoped table. Add `deleted_at` only with a stated reason.
16
+
17
+ Map types as follows: money is `numeric`, text is `text`, JSON is `jsonb`, time is `timestamptz`, and
18
+ an enum is `text` with a CHECK constraint. Never `float` for money, never `varchar(n)`, never `json`,
19
+ never a native Postgres enum.
20
+
21
+ Index `tenant_id`, every foreign key column, and every column used to fetch a single row. Add any
22
+ other index on measurement, not on suspicion.
23
+
24
+ Declare `ON DELETE RESTRICT` on every foreign key. Use `CASCADE` only where the child cannot exist
25
+ without its parent, and say so where you use it.
26
+
27
+ ## Rationale
28
+
29
+ The class exists because responses are serialized from the entity, which needs `@Exclude` to mark what
30
+ must not leave. `defineEntity` exists because v7 made it the primary path and it keeps the ORM's own
31
+ metadata off the class. The two are separate on purpose.
32
+
33
+ snake_case is not a style choice: Postgres folds an unquoted identifier to lower case, so a camelCase
34
+ column must be quoted in every hand-written query, psql session and dashboard, forever. Singular is the
35
+ strategy's default, so it costs nothing and makes the table name match the class name.
36
+
37
+ `numeric` because floating point does not represent 0.10 exactly and the error accumulates across a
38
+ sum. `text` because it performs identically to `varchar(n)` in Postgres while adding no limit that
39
+ hurts to change. `jsonb` because `json` stores raw text and cannot be indexed. A CHECK rather than a
40
+ native enum because adding a value to a native enum is painless but removing or reordering one
41
+ requires recreating the type and rewriting every dependent column.
42
+
43
+ The three mandatory indexes need no measurement, because they are consequences of the design.
44
+ **Postgres does not index the referencing side of a foreign key** (only the referenced side, by virtue
45
+ of being a primary key), so `order.customer_id` has no index unless someone declares it, and that is
46
+ the most commonly forgotten index there is.
47
+
48
+ `RESTRICT` matters because deletes are real here. `CASCADE` by default would remove a customer's
49
+ orders, invoices and payments in one transaction and return success, with no error and no warning.
50
+ `RESTRICT` makes that command fail and forces the decision into explicit code.
51
+
52
+ ## Applies to
53
+
54
+ Every entity file, which lives inside its module's `entities/` directory.
55
+
56
+ ## Examples
57
+
58
+ Naming:
59
+
60
+ ```
61
+ ✅ class User {} → table user, column created_at
62
+ ❌ class User {} → table users, column createdAt
63
+ ```
64
+
65
+ Money and enums:
66
+
67
+ ```
68
+ ✅ amount: numeric status: text + CHECK (status IN (...))
69
+ ❌ amount: double precision status: CREATE TYPE ... AS ENUM
70
+ ```
71
+
72
+ Declaring text:
73
+
74
+ ```
75
+ ✅ displayName: p.text() → text
76
+ ❌ displayName: p.string() → varchar(255)
77
+ ```
78
+
79
+ Deletes:
80
+
81
+ ```
82
+ ✅ ON DELETE RESTRICT
83
+ ❌ ON DELETE CASCADE // on customer → order
84
+ ```
85
+
86
+ ## Enforcement
87
+
88
+ **Migration review.** Every rule here shows up as SQL in a migration, which is the one place a human
89
+ reads the schema as text. That makes migration review the real check, and the reason migrations are
90
+ generated as readable SQL rather than applied invisibly.
91
+
92
+ **Review only.** That a new table carries the structural columns, that a foreign key got its index, and
93
+ that each `CASCADE` was deliberate.
94
+
95
+ **A caveat worth knowing:** `user` is a reserved word in Postgres. `SELECT * FROM user` in psql returns
96
+ the session user, not the table, and does not error. Quote it: `FROM "user"`. The ORM always quotes
97
+ identifiers, so this only bites hand-written queries.
@@ -0,0 +1,74 @@
1
+ # Migrations
2
+
3
+ ## Rule
4
+
5
+ Generate a migration from the entity diff, then read and edit it before committing. Treat what the
6
+ generator emits as a draft.
7
+
8
+ Run migrations as a separate pipeline step, before the new version is deployed. Never at application
9
+ boot, and never by hand.
10
+
11
+ Make every migration compatible with the **previous** release. Removing a column is two deployments:
12
+ one that stops using it, one that removes it.
13
+
14
+ Delete the generated `down`. Undoing a schema change is a new migration going forward.
15
+
16
+ Keep DDL and data apart. A migration performs DDL only. Filling existing rows is a separate task, run
17
+ in batches outside the deploy path, and written so it survives running twice.
18
+
19
+ Add a column that existing rows must eventually carry as nullable first. It becomes `NOT NULL` in a
20
+ third deployment, after the backfill finishes.
21
+
22
+ ## Rationale
23
+
24
+ The generator does not see a rename. It sees one column gone and another appeared, and emits
25
+ `DROP COLUMN` plus `ADD COLUMN`: correct against the diff and wrong against the intent, with no tool
26
+ able to tell the difference. Reading the SQL is also what makes migration review the real check for the
27
+ entity rules; unread, that claim has nothing behind it.
28
+
29
+ Running at boot would race N replicas against each other and put every one into a restart loop when a
30
+ migration fails. It would also mean **the process serving requests holds DDL permission**, so any
31
+ injection or compromise could drop a table. A separate step lets the deploy use a privileged user for
32
+ thirty seconds while the application runs as one that only reads and writes rows.
33
+
34
+ During a rolling deploy the previous version is still running against the new schema, so a migration
35
+ that removes something the old code reads breaks production before the new version finishes arriving.
36
+
37
+ A `down` lies. Reversing a migration that dropped a column recreates it empty: the command succeeds,
38
+ the schema returns, the data does not. And because migrations stay compatible with the previous
39
+ release, rolling back the application never required rolling back the schema in the first place.
40
+
41
+ DDL is fast and locks; a backfill is slow and does not need to. Together, the slow one inherits the
42
+ lock and the deploy waits on it.
43
+
44
+ ## Applies to
45
+
46
+ Every file under the migrations directory, and the deploy pipeline.
47
+
48
+ ## Examples
49
+
50
+ A rename, after editing the generated draft:
51
+
52
+ ```
53
+ ✅ ALTER TABLE invoice RENAME COLUMN total TO amount
54
+ ❌ ALTER TABLE invoice DROP COLUMN total;
55
+ ALTER TABLE invoice ADD COLUMN amount numeric
56
+ ```
57
+
58
+ Separating the backfill:
59
+
60
+ ```
61
+ ✅ migration: ALTER TABLE order ADD COLUMN status text
62
+ task: UPDATE order SET status = 'open' WHERE status IS NULL -- batched
63
+ ❌ migration: ALTER TABLE order ADD COLUMN status text NOT NULL DEFAULT 'open'
64
+ ```
65
+
66
+ ## Enforcement
67
+
68
+ **Pipeline.** Migrations run in their own step, so the application cannot apply them and does not hold
69
+ the permission to.
70
+
71
+ **Review only, and it carries most of the weight in this area:** that the generated SQL was actually
72
+ read, that nothing in it is destructive against the previous release, and that a backfill did not get
73
+ folded into a migration. A 200-line migration is the signal that the reading became a skim, and it is
74
+ usually two migrations mixed together.
@@ -0,0 +1,79 @@
1
+ # Queries
2
+
3
+ ## Rule
4
+
5
+ Inject the `EntityManager`. Read with `em.find` and `em.findOne`, write with `em.persist` and
6
+ `em.flush`. **Do not use `@InjectRepository`.**
7
+
8
+ Declare every relation as `Ref<T>` or `Collection`. Access it only through `$`. State `populate` on
9
+ every read that needs the relation.
10
+
11
+ Reach for the QueryBuilder only where the operation does not exist in `em.find`: aggregation, window
12
+ functions, CTEs, upserts. Reach for raw SQL only where the QueryBuilder cannot express it either.
13
+ Write one comment line saying why, at each step down.
14
+
15
+ Apply the tenant rules to raw SQL exactly as to everything else.
16
+
17
+ Return the managed entity from a service. **Only the services of the module that owns an entity mutate
18
+ it.** A controller, an interceptor and a serializer read it and never write to it.
19
+
20
+ ## Rationale
21
+
22
+ MikroORM's own guidance is that the `EntityManager` is the persistence API and repositories are an
23
+ extension point; `persist`, `flush` and `remove` were removed from repositories in v6 for being
24
+ shortcuts to it. Since nothing can be persisted without the `em`, using a repository as well would put
25
+ **two** doors in every service with a boundary somebody decides per method. This rule fights muscle
26
+ memory (every Nest-with-an-ORM example reaches for `@InjectRepository`), which is why it is written
27
+ negatively.
28
+
29
+ `Ref` and `$` turn N+1 from a review item into a compile error, because `Loaded<Entity, Hints>` tracks
30
+ what was populated and accessing `$` on an unloaded relation does not typecheck. N+1 is the most silent
31
+ database defect there is: the page works and merely gets slower as the list grows. Eager loading would
32
+ trade it for over-fetching on every query, invisible at the call site.
33
+
34
+ The comment on a QueryBuilder query exists because a year later nobody can tell whether it is there
35
+ because it was needed or because the author did not know how to express it otherwise, and that
36
+ difference decides whether anyone may simplify it.
37
+
38
+ An entity from `em.find` is managed, so changing it and flushing later writes to the database with no
39
+ `persist` call. That is the Unit of Work, accepted deliberately. The ownership rule is what keeps a
40
+ cosmetic change in an interceptor from becoming a write when some service downstream flushes.
41
+
42
+ ## Applies to
43
+
44
+ Every service. The relation rules also shape how entities are declared.
45
+
46
+ ## Examples
47
+
48
+ The door:
49
+
50
+ ```
51
+ ✅ constructor(private readonly em: EntityManager) {}
52
+ ❌ constructor(@InjectRepository(User) private readonly users: EntityRepository<User>) {}
53
+ ```
54
+
55
+ Relations:
56
+
57
+ ```
58
+ ✅ const u = await em.findOne(User, id, { populate: ['organization'] })
59
+ u.organization.$.name
60
+ ❌ const u = await em.findOne(User, id)
61
+ u.organization.name
62
+ ```
63
+
64
+ Dropping a level:
65
+
66
+ ```
67
+ ✅ // QueryBuilder: needs a window function, which em.find cannot express
68
+ ❌ // (no comment)
69
+ ```
70
+
71
+ ## Enforcement
72
+
73
+ **Compiler.** Accessing an unpopulated relation through `$` does not typecheck. This is the only
74
+ compile-time guarantee in the area, and it covers the most expensive common defect.
75
+
76
+ **Review only.** That `@InjectRepository` did not creep back in, that each step down the query ladder
77
+ carries its reason, and that nothing outside the owning module's services mutates an entity. Nothing prevents the
78
+ last one (a managed entity and a detached one have the same shape), and its failure is silent: a
79
+ change made for display becomes a write on the next flush of that request.
@@ -0,0 +1,82 @@
1
+ # Testing against the database
2
+
3
+ ## Rule
4
+
5
+ Start one Postgres container per test run with Testcontainers. Give each Vitest worker its own database
6
+ inside it. Never point a test at a shared or long-lived database.
7
+
8
+ Build the schema by running the migrations, once per worker database. Do not use the schema generator.
9
+ Run Better Auth's migrations first, through its programmatic `getMigrations`, then ours.
10
+
11
+ Truncate every table between tests, in a single `TRUNCATE` statement listing them all, Better Auth's
12
+ included. Do not wrap a test in a transaction and roll it back.
13
+
14
+ Create test data with a factory per entity: a function returning a valid row from defaults and
15
+ accepting overrides. Do not keep a shared fixture file.
16
+
17
+ Pass only what the scenario depends on to a factory. Let everything else come from its defaults.
18
+
19
+ ## Rationale
20
+
21
+ A real Postgres is not optional here: `uuidv7()` does not exist in a fake one, and the default that
22
+ generates every primary key would have to be stubbed away.
23
+
24
+ Vitest runs files in parallel workers, so one shared database means a worker truncating a table while
25
+ another reads it. The test then fails intermittently and people re-run until it passes, which is worse
26
+ than failing honestly. A database per worker keeps the parallelism and makes the isolation real.
27
+
28
+ The schema comes from the migrations because migrations are edited by hand, so what they produce can
29
+ diverge from what the entities describe. Building the test schema from the entities would hide exactly
30
+ that divergence: the suite would run against a schema that exists nowhere while production runs against
31
+ another.
32
+
33
+ Truncation rather than rollback, because the code under test opens its own transactions. A test already
34
+ inside one turns the code's transaction into a savepoint, which behaves differently, and a commit is
35
+ never exercised. Testing a lock inside a wrapper that will roll back tests something adjacent to the
36
+ truth.
37
+
38
+ Better Auth's migrations run first for the same reason they do in production: an application table
39
+ references `auth.user`, so its foreign key cannot be created before the table exists. Its tables are in the
40
+ truncation for the mirror reason: leave them out and the next test's factory collides with a user the
41
+ previous test created. A factory for such a table creates the user first; Better Auth's tables have no entity
42
+ here, so that insert is raw SQL, with its one-line reason.
43
+
44
+ Factories rather than a fixture file, because a fixture grows until a test depends on user number twelve
45
+ having a particular plan, and then neither can be changed alone. A factory call states what the scenario
46
+ needs and leaves the rest implied.
47
+
48
+ ## Applies to
49
+
50
+ Every test that touches the database. Anything that does not (the runner, coverage, file naming, unit
51
+ test conventions) belongs to `core/testing.md`, not here.
52
+
53
+ ## Examples
54
+
55
+ Stating the scenario:
56
+
57
+ ```
58
+ ✅ const u = await makeUser({ plan: 'trial' })
59
+ ❌ const u = fixtures.users[12]
60
+ ```
61
+
62
+ Cleaning up:
63
+
64
+ ```
65
+ ✅ TRUNCATE "user", organization, invoice
66
+ ❌ TRUNCATE "user" // fails: invoice still references it
67
+ ```
68
+
69
+ ## Enforcement
70
+
71
+ **Structural.** A worker cannot see another worker's data, because it cannot reach another worker's
72
+ database. Nothing has to be remembered for that to hold.
73
+
74
+ **The suite itself.** Running migrations rather than generating the schema means a wrong migration fails
75
+ a test instead of a deploy, which is what makes migration review a real check rather than a claim.
76
+
77
+ **Review only.** That a test asserts against data it created, rather than against whatever happened to
78
+ be there.
79
+
80
+ **A note that will save an hour:** with `ON DELETE RESTRICT` on every foreign key, truncating a parent
81
+ table alone fails while children exist. Truncate every table in one statement, or use `CASCADE`.
82
+ Postgres's error message does not suggest either.
@@ -0,0 +1,68 @@
1
+ # Transactions
2
+
3
+ ## Rule
4
+
5
+ Open a transaction only where more than one operation must succeed or fail together. A single
6
+ `em.flush()` is already atomic.
7
+
8
+ Open it with `em.transactional(async em => …)`. Do not use the `@Transactional()` decorator, and never
9
+ write `begin` / `commit` / `rollback` by hand.
10
+
11
+ Keep every network call outside the transaction block: an email, a payment gateway, any HTTP request.
12
+
13
+ Leave the isolation level at READ COMMITTED. Where an invariant depends on reading a row and then
14
+ deciding, lock it with `SELECT … FOR UPDATE`. Do not raise the isolation level.
15
+
16
+ Do not retry a failed transaction. Fix the lock ordering instead.
17
+
18
+ ## Rationale
19
+
20
+ `em.flush()` wraps its changes in an implicit transaction, so an explicit one is not for writing
21
+ correctly; it is for the case where two units of work must happen together. A per-request transaction
22
+ would last as long as the request, so a five-second timeout at an email provider becomes five seconds of
23
+ row locks; under concurrency that becomes a queue and the queue becomes an outage. The database ends up
24
+ paying for someone else's slowness.
25
+
26
+ The callback rather than the decorator, because the decorator makes the whole method the boundary and
27
+ cannot be narrowed. A method that writes two rows and also sends an email would sit entirely inside the
28
+ transaction with nothing in the code showing it. The decorator composes better through `REQUIRED`
29
+ propagation; that is given up because its failure mode is silent and only appears under load.
30
+
31
+ READ COMMITTED does not prevent read-decide-write: two requests read the same balance, both find it
32
+ sufficient, and both write. Nothing fails. `FOR UPDATE` is the local, visible fix: the second
33
+ transaction waits instead of aborting, so it needs no retry. Raising the isolation level would make
34
+ every transaction in the system need retry logic, including the ones that never conflicted.
35
+
36
+ A deadlock is an ordering bug, not bad luck, and retrying hides it: the system slows under load and
37
+ nothing ever fails loudly enough to be investigated. A retry also repeats whatever side effects already
38
+ happened outside the database.
39
+
40
+ ## Applies to
41
+
42
+ Every service method that writes more than once.
43
+
44
+ ## Examples
45
+
46
+ Keeping the network out:
47
+
48
+ ```
49
+ ✅ await em.transactional(async (em) => { … })
50
+ await mailer.send(…)
51
+ ❌ await em.transactional(async (em) => { … ; await mailer.send(…) })
52
+ ```
53
+
54
+ Read, decide, write:
55
+
56
+ ```
57
+ ✅ const acc = await em.findOne(Account, id, { lockMode: LockMode.PESSIMISTIC_WRITE })
58
+ ❌ const acc = await em.findOne(Account, id) // two requests both pass the check
59
+ ```
60
+
61
+ ## Enforcement
62
+
63
+ **Review only, and one line of it matters more than anything else in this area:** that a read-decide-write
64
+ sequence locks the row. Nothing detects a missing `FOR UPDATE`. The failure is silent and expensive
65
+ (a negative balance, stock sold twice), and no transaction errors when it happens.
66
+
67
+ Also review: that no network call sits inside a transaction block, and that nobody added a retry
68
+ wrapper.
@@ -0,0 +1,83 @@
1
+ # Components
2
+
3
+ ## Rule
4
+
5
+ Style with NativeWind for everything it covers. Reach for a style object or `StyleSheet` only where an API
6
+ requires one (an animated style, a native prop that takes no `className`), and write one comment line
7
+ naming the API that required it.
8
+
9
+ Express variants with `cva`, in every component.
10
+
11
+ Extract a repeated class list on its **third** occurrence, and extract it as a component, never as a
12
+ string constant.
13
+
14
+ Do not sort classes automatically.
15
+
16
+ Put text styling on the `Text` element itself.
17
+
18
+ Treat `text-base` as *the base size on this platform*. Write an absolute value where a measurement must
19
+ match the web exactly.
20
+
21
+ ## Rationale
22
+
23
+ The ladder off NativeWind is the same shape as the one off `em.find` in the database rules, because the
24
+ problem is the same: with no permission to step down, somebody forces the upper tool to do what it cannot;
25
+ with unlimited permission, the lower one becomes the default because it is familiar. The comment answers,
26
+ a year later, whether that `StyleSheet` was required or merely unfamiliar, which decides whether anyone
27
+ may simplify it.
28
+
29
+ **NativeWind's `rem` is 16 on web and 14 on native**, matching each platform's own default. `text-base` is
30
+ therefore not the same size in the two apps. On mobile alone that is invisible and correct; in a monorepo
31
+ it is the source of *why is the button smaller in the app?*, with the answer in neither codebase because
32
+ both say `text-base`. Forcing them to match would make every piece of text larger than in every other app
33
+ on the device, trading consistency with the platform for consistency with the other app of the same
34
+ product, and the user only sees the first.
35
+
36
+ NativeWind was chosen so one styling language would cross both clients. The **language** crosses; the
37
+ **scale** does not.
38
+
39
+ `cva` and the third-repetition rule carry over from the web area unchanged, for the reasons given there.
40
+
41
+ ## Applies to
42
+
43
+ Every component under `src/`.
44
+
45
+ ## Examples
46
+
47
+ Stepping off NativeWind:
48
+
49
+ ```
50
+ ✅ // Reanimated requires a style object for animated values
51
+ <Animated.View style={animatedStyle} className="rounded-lg" />
52
+ ❌ <Animated.View style={{ padding: 16, borderRadius: 8 }} />
53
+ ```
54
+
55
+ Matching the web exactly:
56
+
57
+ ```
58
+ ✅ <View style={{ height: 48 }} /> // must match the web header
59
+ ❌ <View className="h-12" /> // 42dp here, 48px there
60
+ ```
61
+
62
+ ## Enforcement
63
+
64
+ **Compiler.** `cva` makes variant names typed, so a misspelled variant does not build.
65
+
66
+ **Review only.** That a style object carries its reason, and that a third repetition was extracted.
67
+
68
+ **Five things worth knowing rather than discovering:**
69
+
70
+ NativeWind's Babel JSX runtime injects `react-native-css-interop` into the app's own files, so the app declares
71
+ that package even though no line of it is written by hand; without it the bundle cannot resolve the import.
72
+
73
+ NativeWind 4 uses Tailwind 3's `@tailwind` directives, which Biome does not recognise, so that one rule is off for
74
+ `global.css` alone.
75
+
76
+ The same class is not the same measurement across web and mobile. The language crosses; the scale does
77
+ not.
78
+
79
+ React Native does not cascade styles the way the web does, so text styling belongs on the `Text` element
80
+ rather than on an ancestor.
81
+
82
+ Automatic class sorting is worth revisiting when Biome's rule leaves the nursery group and its fix becomes
83
+ safe, the same condition recorded for the web.
@@ -0,0 +1,84 @@
1
+ # Configuration
2
+
3
+ ## Rule
4
+
5
+ Embed only values that are public by nature: the API base URL, and a third-party **publishable** key
6
+ where the project uses such a service.
7
+
8
+ **Never embed a secret.** A feature that needs one is performed by the API, and the app calls the API.
9
+
10
+ Read configuration from `EXPO_PUBLIC_*` variables in **one** module that exports a typed object. Use
11
+ static dot notation there. Nothing else in the app touches `process.env`.
12
+
13
+ Verify in the build pipeline that every expected variable exists, and fail the build when one is missing.
14
+ Do it in `app.config.ts`, which Expo evaluates before bundling: throw when a variable is absent. Do not validate
15
+ at runtime.
16
+
17
+ Set the variables in the pipeline's environment. **`expo export` reads them from the environment, not from
18
+ `.env`.**
19
+
20
+ Keep `.env` in `.gitignore` and commit `.env.example` with every variable and a safe sample value.
21
+
22
+ ## Rationale
23
+
24
+ **There is no safe place in an app build.** Whoever holds the `.apk` or `.ipa` reads any string inside it.
25
+ Expo states this directly: *"Do not store sensitive info, such as private keys, in `EXPO_PUBLIC_`
26
+ variables. These variables will be visible in plain-text in your compiled application."*
27
+
28
+ This is the opposite of the API, where an environment variable is the correct place for a secret. The
29
+ criterion here is: **would this still be safe printed on a billboard?** A publishable key is designed for
30
+ exactly that; it is what the name means.
31
+
32
+ Obfuscating a secret instead would be a speed bump, extractable in minutes from the binary. Its worst
33
+ quality is not weakness but the *sense* of protection, which leads to embedding things nobody would embed
34
+ knowing they were exposed.
35
+
36
+ One config module, because Expo inlines only static dot notation (`process.env['X']` is not replaced), so
37
+ every variable must appear literally somewhere. Gathering those reads in one file is what that constraint
38
+ already forces, and it means the module is also the list of what is baked into the binary.
39
+
40
+ Validation belongs to the build because that is where the fix is cheap: a missing variable found then is
41
+ two minutes, and found at boot it is a store submission and a review. At boot there is also nothing left
42
+ to detect: an absent variable was replaced by `undefined` at bundle time, so it is a constant rather than
43
+ a missing value.
44
+
45
+ `app.config.ts` rather than a separate script, because Expo evaluates it on every bundle and every build: there
46
+ is no command that produces a binary without passing through it. **`.env` does not reach it during export:**
47
+ verified with Expo SDK 57, `expo config` loads `.env` while `expo export` evaluated the configuration without it
48
+ and failed. That is harmless in a pipeline, where the variables come from its environment, and surprising on a
49
+ laptop, which is why the template's README says so.
50
+
51
+ ## Applies to
52
+
53
+ The config module, the `.env` files, and the build pipeline.
54
+
55
+ ## Examples
56
+
57
+ What may be embedded:
58
+
59
+ ```
60
+ ✅ EXPO_PUBLIC_API_URL=https://api.example.com
61
+ ❌ EXPO_PUBLIC_STRIPE_SECRET_KEY=sk_live_…
62
+ ❌ EXPO_PUBLIC_DATABASE_URL=postgres://…
63
+ ```
64
+
65
+ How it is read:
66
+
67
+ ```
68
+ ✅ // config.ts
69
+ export const config = { apiUrl: process.env.EXPO_PUBLIC_API_URL }
70
+ ❌ process.env[`EXPO_PUBLIC_${name}`] // not inlined; always undefined
71
+ ❌ process.env.EXPO_PUBLIC_API_URL // scattered through feature files
72
+ ```
73
+
74
+ ## Enforcement
75
+
76
+ **Build.** The pipeline fails when an expected variable is missing, which is the only moment the fix is
77
+ cheap.
78
+
79
+ **Review only, and it is the line that matters:** that nothing secret was added to a variable. Nothing
80
+ breaks if it is: the app works exactly as before, and the value is simply readable by anyone who
81
+ downloads it.
82
+
83
+ A feature that appears to need a secret in the app is a feature that belongs on the server. That is the
84
+ question to ask before adding a variable, not after.
@@ -0,0 +1,74 @@
1
+ # Routing
2
+
3
+ ## Rule
4
+
5
+ Hold the native splash screen until the session has been read from `expo-secure-store`. Only then let the
6
+ router render the authenticated group or redirect to login.
7
+
8
+ Express protection with an authenticated layout group. Do not check for a session inside a screen.
9
+
10
+ When a deep link targets a protected route without a session, keep the intended destination **in memory**
11
+ and navigate to it after a successful login. Do not persist it.
12
+
13
+ Enable `experiments.typedRoutes`. Use absolute paths in every navigation.
14
+
15
+ Let the navigation shape (how many tabs, which stacks) follow the product. It is not a convention.
16
+
17
+ ## Rationale
18
+
19
+ On the web the cookie travels with every request and the server decides, so the client is never in doubt.
20
+ On mobile the session lives in secure storage and reading it is asynchronous, so every cold start has a
21
+ moment where nothing is known yet.
22
+
23
+ **MMKV persists across launches, which makes that moment dangerous rather than merely untidy.** A
24
+ protected screen rendering before the decision can show cached data from the **previous user** for a
25
+ fraction of a second. Holding the splash costs nothing in perception (it is already a native, expected
26
+ moment) while the alternative flash reads as a defect and can show somebody else's data.
27
+
28
+ Checking inside each screen fails differently: the screen written tomorrow does not check.
29
+
30
+ A dropped deep-link destination is the most common reason a notification link "does not work": the person
31
+ taps a specific order, signs in, and lands on the home screen with the order to find by hand. Keeping the
32
+ destination in memory rather than on disk matters because `orders/123` describes intent: it says the order
33
+ exists and that this person wanted it. That value is useful for seconds and should not outlive the
34
+ process. Somebody who closed the app midway through login is not returning expecting to resume.
35
+
36
+ Typed routes are beta, and are enabled because the gain is correctness rather than appearance: a wrong
37
+ `href` is broken navigation, not an untidy string. The web app gained end-to-end route typing from its own
38
+ router; without this, the same typo compiles here and fails there.
39
+
40
+ ## Applies to
41
+
42
+ Every file under `app/`, and the bootstrap that controls the splash.
43
+
44
+ ## Examples
45
+
46
+ Protection:
47
+
48
+ ```
49
+ ✅ app/(app)/_layout.tsx → resolves the session, then renders or redirects
50
+ app/(app)/orders.tsx
51
+ ❌ app/orders.tsx → if (!session) router.replace('/login')
52
+ ```
53
+
54
+ Navigation:
55
+
56
+ ```
57
+ ✅ router.push('/orders/123')
58
+ ❌ router.push('../orders/123') // relative paths are not typed
59
+ ```
60
+
61
+ ## Enforcement
62
+
63
+ **Structural.** A screen inside the authenticated group cannot skip the check, because the layout resolves
64
+ before it renders.
65
+
66
+ **Compiler.** Typed routes reject a misspelled path and a missing or wrongly typed param.
67
+
68
+ **Review only.** That the splash is released only after the session resolves, and that a deep-link
69
+ destination is not written to storage.
70
+
71
+ **An asymmetry with the web area, recorded so nobody corrects it:** there, the generated route tree is
72
+ committed, because without it a clean clone does not typecheck. Here `expo-env.d.ts` is generated and
73
+ gitignored, which is Expo's own convention. That file is the framework's territory, and fighting its
74
+ default `.gitignore` creates friction on every upgrade.