@stjoseph/prumo 0.0.6 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prumo-templates/api/auth.md +51 -95
- package/.prumo-templates/api/config.md +30 -56
- package/.prumo-templates/api/errors.md +64 -44
- package/.prumo-templates/api/logging.md +27 -44
- package/.prumo-templates/api/observability.md +36 -62
- package/.prumo-templates/api/pagination.md +23 -38
- package/.prumo-templates/api/routes.md +67 -48
- package/.prumo-templates/api/structure.md +130 -0
- package/.prumo-templates/client/data.md +4 -3
- package/.prumo-templates/core/conventions.md +10 -7
- package/.prumo-templates/core/testing.md +43 -26
- package/.prumo-templates/core/tooling.md +33 -30
- package/.prumo-templates/database/entities.md +53 -61
- package/.prumo-templates/database/queries.md +34 -47
- package/.prumo-templates/database/transactions.md +28 -38
- package/.prumo-templates/mcp/server.md +54 -0
- package/.prumo-templates/monorepo/dependencies.md +3 -3
- package/.prumo-templates/monorepo/layout.md +1 -1
- package/README.md +6 -6
- package/dist/asker.js +4 -0
- package/dist/cli.js +4 -0
- package/dist/commands.js +2 -0
- package/dist/compose.js +6 -2
- package/dist/context.js +2 -0
- package/dist/generate.js +8 -0
- package/dist/mcp.js +100 -0
- package/dist/questions.js +21 -1
- package/package.json +4 -3
- package/templates/api/.swcrc +13 -0
- package/templates/api/biome.jsonc +5 -3
- package/templates/api/package.json +21 -27
- package/templates/api/pnpm-workspace.yaml +0 -2
- package/templates/api/src/domain/shared/errors/domain-error.ts +5 -0
- package/templates/api/src/domain/shared/transactions/transaction-manager.ts +5 -0
- package/templates/api/src/domain/users/dto/profile-response.dto.ts +14 -0
- package/templates/api/src/domain/users/dto/update-profile.dto.ts +10 -0
- package/templates/api/src/domain/users/entities/profile.entity.ts +26 -0
- package/templates/api/src/domain/users/errors/profile-not-found.error.ts +9 -0
- package/templates/api/src/domain/users/repositories/profile.repository.ts +8 -0
- package/templates/api/src/domain/users/use-cases/create-profile.use-case.ts +19 -0
- package/templates/api/src/domain/users/use-cases/find-profile.use-case.ts +22 -0
- package/templates/api/src/domain/users/use-cases/update-profile.use-case.ts +26 -0
- package/templates/api/src/infra/auth/auth.cli.ts +5 -0
- package/templates/api/src/{auth → infra/auth}/auth.factory.ts +26 -1
- package/templates/api/src/infra/config/env.ts +47 -0
- package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +16 -0
- package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +28 -0
- package/templates/api/src/{mikro-orm.config.ts → infra/database/mikroorm/mikro-orm.config.ts} +1 -1
- package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +17 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-profile.repository.ts +17 -0
- package/templates/api/src/infra/database/mikroorm/transactions/mikroorm-transaction-manager.ts +14 -0
- package/templates/api/src/infra/di/index.ts +32 -0
- package/templates/api/src/infra/di/users.di.ts +7 -0
- package/templates/api/src/infra/http/app.ts +99 -0
- package/templates/api/src/infra/http/controllers/auth.controller.ts +36 -0
- package/templates/api/src/infra/http/controllers/health.controller.ts +19 -0
- package/templates/api/src/infra/http/controllers/mcp.controller.ts +40 -0
- package/templates/api/src/infra/http/controllers/users.controller.ts +35 -0
- package/templates/api/src/infra/http/errors/error-handler.ts +116 -0
- package/templates/api/src/infra/http/errors/http-error.ts +8 -0
- package/templates/api/src/infra/http/fastify.d.ts +17 -0
- package/templates/api/src/infra/http/fetch-bridge.ts +42 -0
- package/templates/api/src/infra/http/hooks/auth.hook.ts +32 -0
- package/templates/api/src/infra/http/hooks/request-context.hook.ts +9 -0
- package/templates/api/src/infra/http/server.ts +34 -0
- package/templates/api/src/infra/mcp/mcp.server.ts +20 -0
- package/templates/api/src/infra/mcp/tools/users.tool.ts +39 -0
- package/templates/api/test/domain/users/use-cases/find-profile.use-case.spec.ts +22 -0
- package/templates/api/test/domain/users/use-cases/update-profile.use-case.spec.ts +26 -0
- package/templates/api/{src → test/infra}/auth/auth.factory.spec.ts +4 -11
- package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +8 -0
- package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-profile.repository.spec.ts +51 -0
- package/templates/api/test/infra/database/mikroorm/transactions/mikroorm-transaction-manager.spec.ts +29 -0
- package/templates/api/test/infra/http/app.spec.ts +21 -0
- package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +152 -0
- package/templates/api/test/infra/http/controllers/users.controller.spec.ts +105 -0
- package/templates/api/test/support/factories/profile.factory.ts +25 -0
- package/templates/api/test/{factories → support/factories}/user.factory.ts +1 -1
- package/templates/api/test/support/fakes/in-memory-profile.repository.ts +14 -0
- package/templates/api/test/{setup.ts → support/setup.ts} +26 -14
- package/templates/api/tsconfig.json +3 -6
- package/templates/api/vitest.config.mts +4 -4
- package/templates/mobile/jest.config.js +1 -1
- package/templates/mobile/package.json +2 -2
- package/templates/mobile/{src → test}/features/auth/authenticated-layout.spec.tsx +1 -1
- package/templates/mobile/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
- package/templates/mobile/{src → test}/features/auth/sign-out-button.spec.tsx +2 -2
- package/templates/mobile/{src → test}/features/profile/profile-form.spec.tsx +2 -2
- package/templates/mobile/tsconfig.json +2 -1
- package/templates/site/{src → test}/app/page.spec.ts +1 -1
- package/templates/site/tsconfig.json +2 -1
- package/templates/site/vitest.config.mts +4 -2
- package/templates/web/package.json +4 -2
- package/templates/web/src/features/auth/auth-client.ts +5 -0
- package/templates/web/src/features/auth/sign-in-form.tsx +10 -3
- package/templates/web/src/features/oauth/consent-form.tsx +63 -0
- package/templates/web/src/routeTree.gen.ts +21 -2
- package/templates/web/src/routes/_authenticated/consent.tsx +21 -0
- package/templates/web/{src → test}/app.spec.tsx +2 -2
- package/templates/web/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
- package/templates/web/test/features/oauth/consent-form.spec.tsx +45 -0
- package/templates/web/{src → test}/features/profile/profile-form.spec.tsx +2 -2
- package/templates/web/test/{render-app.tsx → support/render-app.tsx} +1 -1
- package/templates/web/tsconfig.json +2 -1
- package/templates/web/vite.config.ts +3 -3
- package/.prumo-templates/api/modules.md +0 -117
- package/templates/api/nest-cli.json +0 -10
- package/templates/api/src/app.module.ts +0 -46
- package/templates/api/src/auth/auth.constants.ts +0 -2
- package/templates/api/src/auth/auth.controller.ts +0 -19
- package/templates/api/src/auth/auth.guard.ts +0 -43
- package/templates/api/src/auth/auth.module.ts +0 -30
- package/templates/api/src/auth/auth.ts +0 -4
- package/templates/api/src/auth/current-user.decorator.ts +0 -9
- package/templates/api/src/auth/express.d.ts +0 -10
- package/templates/api/src/auth/public.decorator.ts +0 -4
- package/templates/api/src/config/env.module.ts +0 -11
- package/templates/api/src/config/env.ts +0 -81
- package/templates/api/src/errors/problem-details.filter.ts +0 -86
- package/templates/api/src/errors/validation-exception.factory.ts +0 -30
- package/templates/api/src/health/health.controller.ts +0 -32
- package/templates/api/src/health/health.module.ts +0 -9
- package/templates/api/src/main.ts +0 -43
- package/templates/api/src/mikro-orm.factory.ts +0 -14
- package/templates/api/src/request-context/request-context.module.ts +0 -9
- package/templates/api/src/request-context/request-context.service.ts +0 -17
- package/templates/api/src/request-context/request-id.middleware.ts +0 -19
- package/templates/api/src/users/dto/update-profile.dto.ts +0 -20
- package/templates/api/src/users/entities/profile.entity.ts +0 -27
- package/templates/api/src/users/users.controller.ts +0 -23
- package/templates/api/src/users/users.module.ts +0 -10
- package/templates/api/src/users/users.service.spec.ts +0 -44
- package/templates/api/src/users/users.service.ts +0 -36
- package/templates/api/test/factories/profile.factory.ts +0 -26
- package/templates/api/tsconfig.build.json +0 -8
- /package/templates/api/test/{global-setup.ts → support/global-setup.ts} +0 -0
- /package/templates/mobile/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
- /package/templates/web/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
- /package/templates/web/test/{setup.ts → support/setup.ts} +0 -0
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## Rule
|
|
4
4
|
|
|
5
|
-
Run tests with Vitest, **except in `mobile`, which runs Jest with the `jest-expo` preset.** Put
|
|
6
|
-
file it tests
|
|
5
|
+
Run tests with Vitest, **except in `mobile`, which runs Jest with the `jest-expo` preset.** Put every test under
|
|
6
|
+
`test/`, at the path of the file it tests with `src/` replaced by `test/`, named `*.spec.ts`. Put what tests share
|
|
7
|
+
(setup, factories, fakes, a fake transport) under `test/support/`. Import code with `@/` and support with `@test/`,
|
|
8
|
+
never with a relative path.
|
|
7
9
|
|
|
8
|
-
**Do not mock the database.**
|
|
9
|
-
|
|
10
|
+
**Do not mock the database.** In `api`, test a use case against an in-memory fake of its port, a class of
|
|
11
|
+
ours in `test/support/fakes/` that implements the port's interface. Test a repository adapter against the real
|
|
12
|
+
Postgres, and a route through `buildApp()` and `inject()`, also against the real Postgres.
|
|
10
13
|
|
|
11
|
-
Do not mock `EntityManager
|
|
14
|
+
Do not mock `EntityManager` or any concrete class from a library.
|
|
12
15
|
|
|
13
16
|
**In a client, replace the network through the injected transport**: pass a fake `fetch` to `createClient`.
|
|
14
17
|
Do not add a request-mocking library. Test a component that shows an error by rendering it and asserting
|
|
@@ -20,19 +23,24 @@ Assert behaviour, not execution. A test that calls a function without asserting
|
|
|
20
23
|
|
|
21
24
|
## Rationale
|
|
22
25
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
`src/` holds only what ships, so a folder of product code reads as the product, and everything that exists only
|
|
27
|
+
to test it lives in one tree with its setup and fakes beside it. Mirroring `src/` keeps finding a test mechanical:
|
|
28
|
+
the path is the file's own. The aliases keep an import independent of how deep a test sits, so moving a test never
|
|
29
|
+
breaks one.
|
|
30
|
+
|
|
31
|
+
**What it costs:** a mirrored tree has to move when the code moves. A test left behind still passes, as an orphan
|
|
32
|
+
whose path no longer matches any file, and nothing reports it. Moving a file means moving its test in the same
|
|
33
|
+
change.
|
|
34
|
+
**A fake of our port is not a mock of the database.** The port is an interface we wrote, the fake
|
|
35
|
+
implements it, and the compiler keeps the two in step; the use case under test has no idea a database
|
|
36
|
+
exists. Mocking `EntityManager` would mean imitating identity map, unit of work and flush: the hardest thing
|
|
37
|
+
in a suite to write and the least trustworthy. Each time that imitation is wrong, the test passes and
|
|
30
38
|
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.**
|
|
39
|
+
permission not to look.** So the SQL is proved where it lives, in the adapter, against the real database.
|
|
32
40
|
|
|
33
41
|
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
|
-
|
|
42
|
+
dissolves the *unit or integration* argument every project has and nobody wins: the answer is mechanical. A
|
|
43
|
+
use case gets a fake, an adapter gets the database, a route gets both through the real application.
|
|
36
44
|
|
|
37
45
|
A client has no database, and a `web` generated `alone` has no API beside it to test against. The transport is
|
|
38
46
|
already a parameter (the integration layer takes it from the app) so a fake `fetch` needs no library and
|
|
@@ -68,32 +76,41 @@ factories) belongs to the database area, not here.
|
|
|
68
76
|
Placement:
|
|
69
77
|
|
|
70
78
|
```
|
|
71
|
-
✅ features/orders/order-list.tsx
|
|
72
|
-
features/orders/order-list.spec.tsx
|
|
73
|
-
❌
|
|
79
|
+
✅ src/features/orders/order-list.tsx
|
|
80
|
+
test/features/orders/order-list.spec.tsx
|
|
81
|
+
❌ src/features/orders/order-list.spec.tsx beside the file
|
|
82
|
+
❌ test/order-list.spec.tsx not at the file's path
|
|
83
|
+
|
|
84
|
+
✅ import { renderApp } from '@test/support/render-app'
|
|
85
|
+
❌ import { renderApp } from '../../support/render-app'
|
|
74
86
|
```
|
|
75
87
|
|
|
76
|
-
What a
|
|
88
|
+
What a test of the API uses:
|
|
77
89
|
|
|
78
90
|
```
|
|
79
|
-
✅
|
|
91
|
+
✅ new UpdateProfileUseCase(new InMemoryProfileRepository()) a use case: our fake
|
|
92
|
+
✅ new MikroOrmProfileRepository(testOrm().em.fork()) an adapter: real Postgres
|
|
93
|
+
❌ new UpdateProfileUseCase({ findByUserId: vi.fn(), save: vi.fn() })
|
|
80
94
|
❌ const em = { findOne: vi.fn(), flush: vi.fn() }
|
|
81
95
|
```
|
|
82
96
|
|
|
83
97
|
Asserting:
|
|
84
98
|
|
|
85
99
|
```
|
|
86
|
-
✅ expect(await
|
|
87
|
-
❌ await
|
|
100
|
+
✅ expect(await activate.execute(id)).toMatchObject({ status: 'active' })
|
|
101
|
+
❌ await activate.execute(id) // covers the line, checks nothing
|
|
88
102
|
```
|
|
89
103
|
|
|
90
104
|
## Enforcement
|
|
91
105
|
|
|
92
|
-
**
|
|
93
|
-
|
|
106
|
+
**Compiler.** A fake implements the port's interface, so a port that changes breaks every fake that no
|
|
107
|
+
longer matches.
|
|
108
|
+
|
|
109
|
+
**Configuration.** Each runner collects only `test/**`. That also means a spec written beside its file under
|
|
110
|
+
`src/` is never run, and nothing says so.
|
|
94
111
|
|
|
95
|
-
**Review only.** That a test asserts something, that a new module arrived with tests at all,
|
|
96
|
-
|
|
112
|
+
**Review only.** That a test asserts something, that a new module arrived with tests at all, that nobody
|
|
113
|
+
introduced a mock of a library class, that no spec sits under `src/`, and that a moved file took its test along.
|
|
97
114
|
|
|
98
115
|
**The known gap:** with no threshold, coverage can fall and CI stays green. Review is the only defence, and
|
|
99
116
|
review tires. If a floor is ever added, the honest form is on the diff (new code arrives tested) rather
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
## Rule
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
`
|
|
5
|
+
Configure TypeScript with `module` and `moduleResolution` both `nodenext`, `target` `ES2023`, `strict` on,
|
|
6
|
+
`strictPropertyInitialization` off.
|
|
7
7
|
|
|
8
8
|
Add `noUncheckedIndexedAccess`. Do not add `exactOptionalPropertyTypes`.
|
|
9
9
|
|
|
@@ -13,13 +13,16 @@ modules.
|
|
|
13
13
|
Run Biome's recommended preset, configured in `biome.jsonc`. Write a one-line reason in the configuration
|
|
14
14
|
for any rule added or disabled.
|
|
15
15
|
|
|
16
|
-
In `api`, enable `
|
|
16
|
+
In `api`, enable `experimentalDecorators` and `emitDecoratorMetadata`, enable Biome's
|
|
17
|
+
`javascript.parser.unsafeParameterDecoratorsEnabled`, turn `style.useImportType` off, and turn
|
|
18
|
+
`performance.noBarrelFile` on. Run TypeScript through SWC: `@swc-node/register` in development,
|
|
19
|
+
`unplugin-swc` in tests, `@swc/cli` for the build. Never `tsx`.
|
|
17
20
|
|
|
18
21
|
In a client using shadcn, turn `a11y.noLabelWithoutControl` and `a11y.useSemanticElements` off for
|
|
19
22
|
`components/ui/` only, through an override. Keep both on everywhere else.
|
|
20
23
|
|
|
21
|
-
**Never import a class with `import type` where
|
|
22
|
-
dependency
|
|
24
|
+
**Never import a class with `import type` where tsyringe reads its type at runtime**: a constructor
|
|
25
|
+
dependency injected without `@inject`.
|
|
23
26
|
|
|
24
27
|
Run `biome check --staged --write` in `.githooks/pre-commit`. Run `tsc --noEmit` in `.githooks/pre-push`.
|
|
25
28
|
Wire both with `"prepare": "node .githooks/install.mjs"`, which sets `core.hooksPath` inside a Git repository,
|
|
@@ -27,13 +30,12 @@ does nothing outside one, and fails when Git is present and the setting cannot b
|
|
|
27
30
|
|
|
28
31
|
## Rationale
|
|
29
32
|
|
|
30
|
-
|
|
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
|
+
`nodenext` is one of the three values MikroORM v7 accepts, and it is what Node itself resolves.
|
|
33
34
|
|
|
34
|
-
`strictPropertyInitialization` is off because
|
|
35
|
-
constructor, so the flag reports an error about something correct. The cost is real
|
|
36
|
-
with it off, **any** class may declare a property that is never assigned and the
|
|
35
|
+
`strictPropertyInitialization` is off because an entity's columns are filled by hydration or by the
|
|
36
|
+
database, not by its constructor, so the flag reports an error about something correct. The cost is real
|
|
37
|
+
and wider than entities: with it off, **any** class may declare a property that is never assigned and the
|
|
38
|
+
compiler stays quiet.
|
|
37
39
|
|
|
38
40
|
`noUncheckedIndexedAccess` is added because `rows[0]` treated as present is the most common source of a
|
|
39
41
|
runtime `undefined`. `exactOptionalPropertyTypes` is left out because it is the one strictness flag whose
|
|
@@ -48,17 +50,21 @@ the machine, which is what pre-push is.
|
|
|
48
50
|
`biome.jsonc` rather than `biome.json`, because the reason has to live beside the rule and JSON cannot hold
|
|
49
51
|
a comment.
|
|
50
52
|
|
|
51
|
-
**Biome cannot parse
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
**Biome cannot parse the API without the parser option.** `@inject(TOKEN)` on a constructor parameter
|
|
54
|
+
belongs to the legacy decorator proposal, and Biome reports every one as a syntax error, not a lint warning,
|
|
55
|
+
until the option is on.
|
|
54
56
|
|
|
55
|
-
**`useImportType` breaks injection, and nothing reports it.**
|
|
56
|
-
compiler emits into the constructor's metadata. `import type` is erased from the
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
**`useImportType` breaks injection, and nothing reports it.** tsyringe resolves a dependency injected by
|
|
58
|
+
class from the type the compiler emits into the constructor's metadata. `import type` is erased from the
|
|
59
|
+
output, so the emitted type becomes `Object`, and resolving the class fails with *TypeInfo not known for
|
|
60
|
+
"Object"*. It type-checks, and because a use case is resolved per request, it fails on the first request to
|
|
61
|
+
that route rather than at boot. The rule's autofix would make that rewrite across the codebase in one pass,
|
|
62
|
+
and written by hand the same `import type` does the same damage, which is why the rule forbids the import and
|
|
63
|
+
not only the lint rule.
|
|
64
|
+
|
|
65
|
+
**SWC and never `tsx`** because tsyringe needs the decorator metadata, and esbuild, which `tsx` runs on, does
|
|
66
|
+
not emit it: a class injected by type fails in development only, while build and tests pass. SWC is the one
|
|
67
|
+
transformer that emits it in all three places, and `tsc` only checks types.
|
|
62
68
|
|
|
63
69
|
**The shadcn override is scoped because both rules are right in application code.** A generated `Label`
|
|
64
70
|
receives `htmlFor` through props the rule cannot see, and shadcn's `Field` uses `role="group"` deliberately.
|
|
@@ -87,13 +93,11 @@ Configuration changes:
|
|
|
87
93
|
❌ "noUnusedVariables": "off"
|
|
88
94
|
```
|
|
89
95
|
|
|
90
|
-
Importing what
|
|
96
|
+
Importing what tsyringe reads at runtime:
|
|
91
97
|
|
|
92
98
|
```
|
|
93
|
-
✅ import {
|
|
94
|
-
|
|
95
|
-
❌ import type { UsersService } from './users.service' // boot fails: Function
|
|
96
|
-
❌ import type { UpdateProfileDto } from './dto/update-profile.dto' // nothing is validated
|
|
99
|
+
✅ import { EntityManager } from '@mikro-orm/postgresql'
|
|
100
|
+
❌ import type { EntityManager } from '@mikro-orm/postgresql' // TypeInfo not known for "Object"
|
|
97
101
|
```
|
|
98
102
|
|
|
99
103
|
Reading an array:
|
|
@@ -110,9 +114,8 @@ Reading an array:
|
|
|
110
114
|
**The hooks.** Formatting cannot reach a commit unformatted, and a type error cannot reach the remote,
|
|
111
115
|
unless somebody uses `--no-verify`, or never ran an install and so never got `core.hooksPath` configured.
|
|
112
116
|
|
|
113
|
-
**
|
|
114
|
-
|
|
115
|
-
that sends an invalid body, or by review.
|
|
117
|
+
**Route tests.** A constructor dependency imported as a type fails the first request that resolves it, so a
|
|
118
|
+
route test covering that use case catches it. A use case tested only through its fake does not.
|
|
116
119
|
|
|
117
120
|
**Review only.** That a disabled rule carries its reason, and that `strictPropertyInitialization` being
|
|
118
|
-
off did not leave an uninitialised property somewhere outside
|
|
121
|
+
off did not leave an uninitialised property somewhere outside an entity.
|
|
@@ -2,96 +2,88 @@
|
|
|
2
2
|
|
|
3
3
|
## Rule
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
Write an entity as a plain class in `src/domain/<module>/entities/<name>.entity.ts`, with its data and its
|
|
6
|
+
behaviour. Map it in `src/infra/database/mikroorm/entities/<name>.schema.ts` with
|
|
7
|
+
`new EntitySchema({ class })`, and list the schema in `mikro-orm.factory.ts`.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
Import nothing from the ORM in an entity, except `Ref`, `ref` and `Collection` from `@mikro-orm/core` for a
|
|
10
|
+
relation. Declare a to-one relation as `Ref<T>` and a to-many one as `Collection<T>`.
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
Keep the default `UnderscoreNamingStrategy`. Table names are singular and match the class name; columns are
|
|
13
|
+
snake_case. Do not configure a naming strategy and do not set `tableName`.
|
|
14
|
+
|
|
15
|
+
Give every table `id`, `created_at` and `updated_at`. Let `id` be `uuid` with `defaultRaw: 'uuidv7()'`. Let
|
|
16
|
+
both timestamps be `datetime`, which is `timestamptz`, maintained by `onCreate` and `onUpdate`.
|
|
14
17
|
|
|
15
18
|
Add `tenant_id` to every tenant-scoped table. Add `deleted_at` only with a stated reason.
|
|
16
19
|
|
|
17
|
-
Map types as follows: money is `numeric`, text is `text`, JSON is `jsonb`, time is `
|
|
18
|
-
|
|
19
|
-
never a native Postgres enum.
|
|
20
|
+
Map types as follows: money is `numeric`, text is `text`, JSON is `jsonb`, time is `datetime`, and an enum
|
|
21
|
+
is `text` with a CHECK constraint. Never `float` for money, never `string`, which is `varchar(255)`, never
|
|
22
|
+
`json`, never a native Postgres enum.
|
|
20
23
|
|
|
21
|
-
Index `tenant_id`, every foreign key column, and every column used to fetch a single row. Add any
|
|
22
|
-
|
|
24
|
+
Index `tenant_id`, every foreign key column, and every column used to fetch a single row. Add any other
|
|
25
|
+
index on measurement, not on suspicion.
|
|
23
26
|
|
|
24
|
-
Declare `ON DELETE RESTRICT` on every foreign key. Use `CASCADE` only where the child cannot exist
|
|
25
|
-
|
|
27
|
+
Declare `ON DELETE RESTRICT` on every foreign key. Use `CASCADE` only where the child cannot exist without
|
|
28
|
+
its parent, and say so where you use it.
|
|
26
29
|
|
|
27
30
|
## Rationale
|
|
28
31
|
|
|
29
|
-
The class
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
The class is the domain's, so a use case and its test work with it without a database. The schema is the
|
|
33
|
+
infrastructure's, so the ORM's metadata never reaches the domain. Hydration does not call the constructor,
|
|
34
|
+
so a class can require its essential fields in the constructor and still be loaded from a row.
|
|
35
|
+
|
|
36
|
+
`Ref` and `Collection` are the exception because they carry the one compile-time guarantee in the area: an
|
|
37
|
+
unpopulated relation has no `$` on its type, so an N+1 does not compile. They also let an aggregate, an order
|
|
38
|
+
and its items, be written by one flush. Nothing else from the ORM is worth that exception.
|
|
32
39
|
|
|
33
|
-
snake_case is not a style choice: Postgres folds an unquoted identifier to lower case, so a camelCase
|
|
34
|
-
|
|
35
|
-
strategy's default, so it costs nothing and makes the table name match the class name.
|
|
40
|
+
snake_case is not a style choice: Postgres folds an unquoted identifier to lower case, so a camelCase column
|
|
41
|
+
must be quoted in every hand-written query, forever. Singular is the strategy's default.
|
|
36
42
|
|
|
37
|
-
`numeric` because floating point does not represent 0.10 exactly and the error accumulates across a
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
requires recreating the type and rewriting every dependent column.
|
|
43
|
+
`numeric` because floating point does not represent 0.10 exactly and the error accumulates across a sum.
|
|
44
|
+
`text` because it performs identically to `varchar(n)` in Postgres while adding no limit that hurts to
|
|
45
|
+
change. `jsonb` because `json` cannot be indexed. A CHECK rather than a native enum because removing or
|
|
46
|
+
reordering a native enum value requires recreating the type.
|
|
42
47
|
|
|
43
|
-
|
|
44
|
-
|
|
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.
|
|
48
|
+
**Postgres does not index the referencing side of a foreign key**, so `order.customer_id` has no index
|
|
49
|
+
unless someone declares it: the most commonly forgotten index there is.
|
|
47
50
|
|
|
48
|
-
`RESTRICT` matters because deletes are real here. `CASCADE` by default would remove a customer's
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
`RESTRICT` matters because deletes are real here. `CASCADE` by default would remove a customer's orders,
|
|
52
|
+
invoices and payments in one transaction and return success. `RESTRICT` makes that fail and forces the
|
|
53
|
+
decision into code.
|
|
51
54
|
|
|
52
55
|
## Applies to
|
|
53
56
|
|
|
54
|
-
Every
|
|
57
|
+
Every file under `domain/<module>/entities/` and `infra/database/mikroorm/entities/`.
|
|
55
58
|
|
|
56
59
|
## Examples
|
|
57
60
|
|
|
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
61
|
```
|
|
62
|
+
✅ class Profile { … } no ORM import
|
|
63
|
+
❌ class Profile extends ProfileSchema.class the schema leaks into the domain
|
|
71
64
|
|
|
72
|
-
|
|
65
|
+
✅ customer: Ref<Customer> items = new Collection<OrderItem>(this)
|
|
66
|
+
❌ customer: Customer an unpopulated relation looks loaded
|
|
73
67
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
❌ displayName: p.string() → varchar(255)
|
|
77
|
-
```
|
|
68
|
+
✅ displayName: { type: 'text' }
|
|
69
|
+
❌ displayName: { type: 'string' } varchar(255)
|
|
78
70
|
|
|
79
|
-
|
|
71
|
+
✅ class User {} → table user, column created_at
|
|
72
|
+
❌ class User {} → table users, column createdAt
|
|
80
73
|
|
|
81
|
-
```
|
|
82
74
|
✅ ON DELETE RESTRICT
|
|
83
|
-
❌ ON DELETE CASCADE
|
|
75
|
+
❌ ON DELETE CASCADE on customer → order
|
|
84
76
|
```
|
|
85
77
|
|
|
86
78
|
## Enforcement
|
|
87
79
|
|
|
88
|
-
**
|
|
89
|
-
|
|
90
|
-
|
|
80
|
+
**Compiler.** Reading `$` on an unpopulated relation does not typecheck.
|
|
81
|
+
|
|
82
|
+
**Migration review.** Every other rule here shows up as SQL in a migration, the one place a human reads the
|
|
83
|
+
schema as text.
|
|
91
84
|
|
|
92
|
-
**Review only.** That
|
|
93
|
-
that each `CASCADE` was deliberate.
|
|
85
|
+
**Review only.** That an entity imports nothing else from the ORM, that a new table carries the structural
|
|
86
|
+
columns, that a foreign key got its index, and that each `CASCADE` was deliberate.
|
|
94
87
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
identifiers, so this only bites hand-written queries.
|
|
88
|
+
`user` is a reserved word in Postgres: `SELECT * FROM user` returns the session user and does not error.
|
|
89
|
+
Quote it in hand-written SQL.
|
|
@@ -2,78 +2,65 @@
|
|
|
2
2
|
|
|
3
3
|
## Rule
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
`em.flush`.
|
|
5
|
+
Query only inside a repository adapter in `src/infra/database/mikroorm/repositories/`. It injects the
|
|
6
|
+
`EntityManager`; read with `em.find` and `em.findOne`, write with `em.persist` and `em.flush`. Do not use
|
|
7
|
+
MikroORM's `EntityRepository`: the port is ours.
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
Give a port one method per question the domain asks, named for it: `findByUserId`, not a generic `find`
|
|
10
|
+
taking a filter.
|
|
11
|
+
|
|
12
|
+
State `populate` on every read that needs a relation, and access a relation only through `$`.
|
|
10
13
|
|
|
11
14
|
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
|
-
|
|
15
|
+
functions, CTEs, upserts. Reach for raw SQL only where the QueryBuilder cannot express it either. Write one
|
|
16
|
+
comment line saying why, at each step down.
|
|
14
17
|
|
|
15
18
|
Apply the tenant rules to raw SQL exactly as to everything else.
|
|
16
19
|
|
|
17
|
-
Return the managed entity from
|
|
18
|
-
it
|
|
20
|
+
Return the managed entity from the repository. Only the use cases of the module that owns an entity change
|
|
21
|
+
it.
|
|
19
22
|
|
|
20
23
|
## Rationale
|
|
21
24
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
The adapter is the only file that knows the database, so a use case reads as the product's rule and its
|
|
26
|
+
test replaces the adapter with a fake. A method per question keeps the port honest: a generic filter would
|
|
27
|
+
leak the ORM's query language into the domain, and the fake would have to reimplement it.
|
|
28
|
+
|
|
29
|
+
`EntityRepository` is refused because it would be a second repository beside ours, with the same name and a
|
|
30
|
+
different contract.
|
|
28
31
|
|
|
29
|
-
`Ref` and `$` turn N+1 from a review item into a compile error, because `Loaded<Entity, Hints>` tracks
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
trade it for over-fetching on every query, invisible at the call site.
|
|
32
|
+
`Ref` and `$` turn N+1 from a review item into a compile error, because `Loaded<Entity, Hints>` tracks what
|
|
33
|
+
was populated. N+1 is the most silent database defect there is: the page works and merely gets slower as the
|
|
34
|
+
list grows.
|
|
33
35
|
|
|
34
|
-
The comment on a
|
|
35
|
-
|
|
36
|
-
difference decides whether anyone may simplify it.
|
|
36
|
+
The comment on a lower-level query exists because a year later nobody can tell whether it is there because
|
|
37
|
+
it was needed or because the author did not know how to express it otherwise.
|
|
37
38
|
|
|
38
|
-
An entity from `em.find` is managed, so changing it and flushing
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
An entity from `em.find` is managed, so changing it and flushing writes it with no `persist` call. That is
|
|
40
|
+
the Unit of Work, accepted deliberately. The ownership rule keeps a change made for display from becoming a
|
|
41
|
+
write when something downstream flushes.
|
|
41
42
|
|
|
42
43
|
## Applies to
|
|
43
44
|
|
|
44
|
-
Every
|
|
45
|
+
Every repository adapter and every port. The relation rules also shape how entities are declared.
|
|
45
46
|
|
|
46
47
|
## Examples
|
|
47
48
|
|
|
48
|
-
The door:
|
|
49
|
-
|
|
50
|
-
```
|
|
51
|
-
✅ constructor(private readonly em: EntityManager) {}
|
|
52
|
-
❌ constructor(@InjectRepository(User) private readonly users: EntityRepository<User>) {}
|
|
53
49
|
```
|
|
50
|
+
✅ findByUserId(userId: string): Promise<Profile | null>
|
|
51
|
+
❌ find(filter: FilterQuery<Profile>) the ORM's language in the domain
|
|
54
52
|
|
|
55
|
-
|
|
53
|
+
✅ em.findOne(Order, id, { populate: ['customer'] }) then order.customer.$.name
|
|
54
|
+
❌ em.findOne(Order, id) then order.customer.$.name: does not compile
|
|
56
55
|
|
|
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
56
|
✅ // QueryBuilder: needs a window function, which em.find cannot express
|
|
68
|
-
❌
|
|
57
|
+
❌ (no comment)
|
|
69
58
|
```
|
|
70
59
|
|
|
71
60
|
## Enforcement
|
|
72
61
|
|
|
73
|
-
**Compiler.**
|
|
74
|
-
compile-time guarantee in the area, and it covers the most expensive common defect.
|
|
62
|
+
**Compiler.** Reading `$` on an unpopulated relation does not typecheck.
|
|
75
63
|
|
|
76
|
-
**Review only.** That
|
|
77
|
-
|
|
78
|
-
last one (a managed entity and a detached one have the same shape), and its failure is silent: a
|
|
64
|
+
**Review only.** That queries stay in the adapters, that each step down the query ladder carries its reason,
|
|
65
|
+
and that nothing outside the owning module's use cases changes an entity. The last one fails silently: a
|
|
79
66
|
change made for display becomes a write on the next flush of that request.
|
|
@@ -2,67 +2,57 @@
|
|
|
2
2
|
|
|
3
3
|
## Rule
|
|
4
4
|
|
|
5
|
-
Open a transaction only where more than one
|
|
6
|
-
|
|
5
|
+
Open a transaction only where more than one write must succeed or fail together. A single `save()` is
|
|
6
|
+
already atomic.
|
|
7
7
|
|
|
8
|
-
Open it
|
|
9
|
-
write `begin` / `commit` /
|
|
8
|
+
Open it in the use case, through the `TransactionManager` port: `await this.transactions.run(async () => …)`.
|
|
9
|
+
Every repository called inside the callback joins the transaction. Never write `begin` / `commit` /
|
|
10
|
+
`rollback` by hand.
|
|
10
11
|
|
|
11
|
-
Keep every network call outside the
|
|
12
|
+
Keep every network call outside the callback: an email, a payment gateway, any HTTP request.
|
|
12
13
|
|
|
13
|
-
Leave the isolation level at READ COMMITTED. Where an invariant depends on reading a row and then
|
|
14
|
-
|
|
14
|
+
Leave the isolation level at READ COMMITTED. Where an invariant depends on reading a row and then deciding,
|
|
15
|
+
lock it: give the port a method that says so, `findByIdForUpdate`, and implement it with
|
|
16
|
+
`lockMode: LockMode.PESSIMISTIC_WRITE`. Do not raise the isolation level.
|
|
15
17
|
|
|
16
18
|
Do not retry a failed transaction. Fix the lock ordering instead.
|
|
17
19
|
|
|
18
20
|
## Rationale
|
|
19
21
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
paying for someone else's slowness.
|
|
22
|
+
The boundary is the use case's decision, because only the use case knows which writes belong together; the
|
|
23
|
+
port keeps that decision in the domain without importing the ORM. The adapter uses `em.transactional`, and
|
|
24
|
+
inside it the global `EntityManager` resolves to the transaction's fork, which is why repositories need
|
|
25
|
+
nothing passed to them.
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
|
|
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.
|
|
27
|
+
A per-request transaction would last as long as the request, so a five-second timeout at an email provider
|
|
28
|
+
becomes five seconds of row locks; under concurrency that becomes a queue and the queue an outage.
|
|
30
29
|
|
|
31
30
|
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
|
-
|
|
34
|
-
every transaction in the system need retry logic, including the ones that never conflicted.
|
|
31
|
+
sufficient, and both write. Nothing fails. `FOR UPDATE` is the local, visible fix: the second transaction
|
|
32
|
+
waits instead of aborting, so it needs no retry. The lock is a named port method so the use case shows it.
|
|
35
33
|
|
|
36
|
-
A deadlock is an ordering bug,
|
|
37
|
-
nothing ever fails loudly enough to be investigated. A retry also repeats whatever side effects already
|
|
34
|
+
A deadlock is an ordering bug, and retrying hides it. A retry also repeats whatever side effects already
|
|
38
35
|
happened outside the database.
|
|
39
36
|
|
|
40
37
|
## Applies to
|
|
41
38
|
|
|
42
|
-
Every
|
|
39
|
+
Every use case that writes more than once, and `MikroOrmTransactionManager`.
|
|
43
40
|
|
|
44
41
|
## Examples
|
|
45
42
|
|
|
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
43
|
```
|
|
44
|
+
✅ await this.transactions.run(async () => { … })
|
|
45
|
+
await this.mailer.send(…)
|
|
46
|
+
❌ await this.transactions.run(async () => { … ; await this.mailer.send(…) })
|
|
53
47
|
|
|
54
|
-
|
|
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
|
|
48
|
+
✅ const account = await this.accounts.findByIdForUpdate(id)
|
|
49
|
+
❌ const account = await this.accounts.findById(id) two requests both pass the check
|
|
59
50
|
```
|
|
60
51
|
|
|
61
52
|
## Enforcement
|
|
62
53
|
|
|
63
|
-
**
|
|
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.
|
|
54
|
+
**Tests.** The template proves that a failed write inside `run()` rolls back the one before it.
|
|
66
55
|
|
|
67
|
-
|
|
68
|
-
|
|
56
|
+
**Review only, and one line of it matters more than anything else in this area:** that a read-decide-write
|
|
57
|
+
sequence locks the row. Nothing detects a missing lock, and no transaction errors when a balance goes
|
|
58
|
+
negative. Also review that no network call sits inside `run()` and that nobody added a retry.
|