create-warlock 4.2.5 → 4.2.7
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/CHANGELOG.md +14 -0
- package/bin/create-app.js +5 -0
- package/package.json +2 -2
- package/templates/warlock/.env.example +36 -0
- package/templates/warlock/.gitattributes +1 -0
- package/templates/warlock/.husky/pre-commit +4 -0
- package/templates/warlock/.prettierignore +4 -0
- package/templates/warlock/.prettierrc.json +10 -0
- package/templates/warlock/.vscode/settings.json +41 -0
- package/templates/warlock/README.md +57 -0
- package/templates/warlock/_.gitignore +6 -0
- package/templates/warlock/docs/new-module.md +551 -0
- package/templates/warlock/eslint.config.js +98 -0
- package/templates/warlock/package.json +74 -0
- package/templates/warlock/public/home.css +523 -0
- package/templates/warlock/skills/api-design/SKILL.md +461 -0
- package/templates/warlock/skills/code-standards/SKILL.md +595 -0
- package/templates/warlock/skills/data-and-persistence/SKILL.md +330 -0
- package/templates/warlock/skills/git-workflow/SKILL.md +282 -0
- package/templates/warlock/skills/module-boundaries/SKILL.md +283 -0
- package/templates/warlock/skills/observability-and-resilience/SKILL.md +306 -0
- package/templates/warlock/skills/security-baseline/SKILL.md +352 -0
- package/templates/warlock/skills/testing-strategy/SKILL.md +323 -0
- package/templates/warlock/src/app/auth/controllers/forgot-password.controller.ts +28 -0
- package/templates/warlock/src/app/auth/controllers/login.controller.ts +22 -0
- package/templates/warlock/src/app/auth/controllers/logout-all.controller.ts +16 -0
- package/templates/warlock/src/app/auth/controllers/logout.controller.ts +16 -0
- package/templates/warlock/src/app/auth/controllers/me.controller.ts +13 -0
- package/templates/warlock/src/app/auth/controllers/refresh-token.controller.ts +29 -0
- package/templates/warlock/src/app/auth/controllers/reset-password.controller.ts +23 -0
- package/templates/warlock/src/app/auth/main.ts +9 -0
- package/templates/warlock/src/app/auth/models/otp/index.ts +1 -0
- package/templates/warlock/src/app/auth/models/otp/migrations/22-12-2025_10-30-20.otp-migration.ts +30 -0
- package/templates/warlock/src/app/auth/models/otp/otp.model.ts +69 -0
- package/templates/warlock/src/app/auth/requests/guarded.request.ts +10 -0
- package/templates/warlock/src/app/auth/routes.ts +22 -0
- package/templates/warlock/src/app/auth/schema/login.schema.ts +8 -0
- package/templates/warlock/src/app/auth/schema/reset-password.schema.ts +9 -0
- package/templates/warlock/src/app/auth/services/auth.service.ts +66 -0
- package/templates/warlock/src/app/auth/services/forgot-password.service.ts +28 -0
- package/templates/warlock/src/app/auth/services/otp.service.ts +173 -0
- package/templates/warlock/src/app/auth/services/reset-password.service.ts +39 -0
- package/templates/warlock/src/app/auth/utils/auth-error-code.ts +6 -0
- package/templates/warlock/src/app/auth/utils/locales.ts +89 -0
- package/templates/warlock/src/app/auth/utils/types.ts +14 -0
- package/templates/warlock/src/app/posts/controllers/create-new-post.controller.ts +21 -0
- package/templates/warlock/src/app/posts/controllers/update-post.controller.ts +30 -0
- package/templates/warlock/src/app/posts/models/post/migrations/09-01-2026_02-07-51-post.migration.ts +15 -0
- package/templates/warlock/src/app/posts/models/post/post.model.ts +23 -0
- package/templates/warlock/src/app/posts/resources/post.resource.ts +14 -0
- package/templates/warlock/src/app/posts/routes.ts +8 -0
- package/templates/warlock/src/app/posts/schema/create-post.schema.ts +9 -0
- package/templates/warlock/src/app/posts/schema/update-post.schema.ts +9 -0
- package/templates/warlock/src/app/shared/components/HomePageComponent.tsx +229 -0
- package/templates/warlock/src/app/shared/controllers/home-page.controller.ts +18 -0
- package/templates/warlock/src/app/shared/controllers/home-page.controller.tsx +8 -0
- package/templates/warlock/src/app/shared/routes.ts +4 -0
- package/templates/warlock/src/app/shared/services/scheduler.service.ts +3 -0
- package/templates/warlock/src/app/shared/tests/infrastructure.test.ts +22 -0
- package/templates/warlock/src/app/shared/utils/global-columns-schema.ts +8 -0
- package/templates/warlock/src/app/shared/utils/locales.ts +766 -0
- package/templates/warlock/src/app/shared/utils/router.ts +30 -0
- package/templates/warlock/src/app/uploads/controllers/fetch-uploaded-file.controller.ts +33 -0
- package/templates/warlock/src/app/uploads/routes.ts +4 -0
- package/templates/warlock/src/app/users/commands/hello-world.command.ts +8 -0
- package/templates/warlock/src/app/users/controllers/create-new-user.controller.ts +27 -0
- package/templates/warlock/src/app/users/controllers/list-users.controller.ts +12 -0
- package/templates/warlock/src/app/users/events/inject-created-by-user.into-model.event.ts +32 -0
- package/templates/warlock/src/app/users/events/sync.ts +5 -0
- package/templates/warlock/src/app/users/main.ts +5 -0
- package/templates/warlock/src/app/users/models/user/index.ts +1 -0
- package/templates/warlock/src/app/users/models/user/migrations/11-12-2025_23-58-03-user.migration.ts +15 -0
- package/templates/warlock/src/app/users/models/user/user.model.ts +64 -0
- package/templates/warlock/src/app/users/repositories/users.repository.ts +23 -0
- package/templates/warlock/src/app/users/resources/user.resource.ts +14 -0
- package/templates/warlock/src/app/users/routes.ts +8 -0
- package/templates/warlock/src/app/users/schema/create-user.schema.ts +11 -0
- package/templates/warlock/src/app/users/seeds/users.seed.ts +21 -0
- package/templates/warlock/src/app/users/services/get-users.service.ts +5 -0
- package/templates/warlock/src/app/users/services/list-users.service.ts +17 -0
- package/templates/warlock/src/app/users/services/login-social.ts +19 -0
- package/templates/warlock/src/config/app.ts +12 -0
- package/templates/warlock/src/config/auth.ts +20 -0
- package/templates/warlock/src/config/cache.ts +59 -0
- package/templates/warlock/src/config/database.ts +65 -0
- package/templates/warlock/src/config/http.ts +23 -0
- package/templates/warlock/src/config/log.ts +22 -0
- package/templates/warlock/src/config/mail.ts +16 -0
- package/templates/warlock/src/config/repository.ts +11 -0
- package/templates/warlock/src/config/storage.ts +34 -0
- package/templates/warlock/src/config/tests.ts +5 -0
- package/templates/warlock/src/config/validation.ts +7 -0
- package/templates/warlock/storage/.gitignore +2 -0
- package/templates/warlock/tsconfig.json +27 -0
- package/templates/warlock/warlock.config.ts +15 -0
- package/templates/warlock/yarn.lock +2332 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: module-boundaries
|
|
3
|
+
description: 'Architecture rules for how modules under `src/app/**` relate — each module owns one domain noun (`orders`, `users`, `carts`, `payments`); standard layout per module (`controllers/`, `services/`, `models/<noun>/`, `repositories/`, `schema/`, `resources/`, `types/`, `utils/`, `events/`, `errors/`, `routes.ts`); public surface exposed through sub-folder barrels (`services/index.ts`, `utils/index.ts`, `types/index.ts`) — NEVER a module-root `index.ts` re-exporting everything; no module reaches into another module''s internal files; zero circular dependencies between modules; inter-module communication is explicit — direct service call OR `@mongez/events` bus, never shared mutable globals; cross-cutting helpers live in `src/app/shared/`. Triggers: adding a new module under `src/app/**`; importing from another module; deciding where new code belongs; circular import error; ESLint `import/no-restricted-paths` violation; user asks "can module A use module B''s internals", "how should modules talk", "where should this code live", "I''m getting a circular dependency", "what folders does a module need", "what goes in shared". Skip: intra-module file organization (load `skills/code-standards/SKILL.md` § 4); barrel mechanics within a module (load `skills/code-standards/SKILL.md` § 2.2); deploying / packaging / monorepo questions; framework primitive details — load relevant `@warlock.js/*` skill.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Module boundaries
|
|
7
|
+
|
|
8
|
+
**Status:** Stable
|
|
9
|
+
**Applies to:** Every module under `src/app/**` and every import that crosses between modules.
|
|
10
|
+
|
|
11
|
+
A module is a unit of substitution. If two modules are tangled, you can't refactor either without touching both. These rules keep modules independent enough that any one can be rewritten, extracted, or deleted with a contained blast radius.
|
|
12
|
+
|
|
13
|
+
> **Sub-agent rule:** Before adding a new module or importing across modules, read this file.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 1. One domain noun per module
|
|
18
|
+
|
|
19
|
+
### 1.1 The rule
|
|
20
|
+
|
|
21
|
+
A module is named after the noun it owns: `users`, `orders`, `carts`, `payments`, `ai-models`, `chats`. Lowercase, kebab-case, plural.
|
|
22
|
+
|
|
23
|
+
### 1.2 If the name needs an "and", split it
|
|
24
|
+
|
|
25
|
+
`orders-and-payments/` is two modules. So is `users-and-auth/`. Each gets its own folder.
|
|
26
|
+
|
|
27
|
+
### 1.3 Cross-cutting concerns live in `shared/`
|
|
28
|
+
|
|
29
|
+
Auth helpers, logging adapters, enum definitions, router utilities — anything used by more than one domain module — lives in `src/app/shared/`. The existing `src/app/shared/{enums.ts, get-random-color.ts, locales.ts, router.ts, ...}` is the pattern.
|
|
30
|
+
|
|
31
|
+
### 1.4 Match modules to domain boundaries
|
|
32
|
+
|
|
33
|
+
If the business has an "orders team" and a "payments team", you probably have an `orders` module and a `payments` module — not one `commerce` module. Module boundaries should mirror who owns the code and who gets paged when it breaks.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## 2. Standard module layout
|
|
38
|
+
|
|
39
|
+
Every module follows this folder shape. Not every folder is required — only the ones the module actually uses.
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
src/app/<module>/
|
|
43
|
+
controllers/ ← HTTP handlers (one per endpoint)
|
|
44
|
+
<verb>-<noun>.controller.ts
|
|
45
|
+
services/ ← business logic (one per use case)
|
|
46
|
+
index.ts ← sub-folder barrel ✅
|
|
47
|
+
<verb>-<noun>.service.ts
|
|
48
|
+
models/
|
|
49
|
+
<noun>/ ← model folder (Cascade convention)
|
|
50
|
+
<noun>.model.ts
|
|
51
|
+
index.ts
|
|
52
|
+
migrations/
|
|
53
|
+
<timestamp>-<noun>.migration.ts
|
|
54
|
+
repositories/ ← Cascade RepositoryManager subclasses
|
|
55
|
+
<nouns>.repository.ts
|
|
56
|
+
schema/ ← seal schemas (v.object + Infer<>), value + type per file
|
|
57
|
+
<verb>-<noun>.schema.ts
|
|
58
|
+
resources/ ← output mappers (defineResource)
|
|
59
|
+
<noun>.resource.ts
|
|
60
|
+
types/ ← TypeScript types and enums
|
|
61
|
+
index.ts ← sub-folder barrel ✅
|
|
62
|
+
<noun>-status.type.ts
|
|
63
|
+
utils/ ← pure helpers used inside the module
|
|
64
|
+
index.ts ← sub-folder barrel ✅
|
|
65
|
+
<verb>-<noun>.ts
|
|
66
|
+
events/ ← if the module emits events
|
|
67
|
+
<noun>-<verb>.event.ts
|
|
68
|
+
errors/ ← custom error classes
|
|
69
|
+
<noun>-not-found.error.ts
|
|
70
|
+
routes.ts ← the module's route registrations
|
|
71
|
+
README.md ← module purpose, public surface, ownership
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### 2.1 Empty folders are fine to omit
|
|
75
|
+
|
|
76
|
+
Don't create `events/` if the module emits nothing. Don't create `errors/` until you have an error to put there. The layout is a vocabulary, not a checklist.
|
|
77
|
+
|
|
78
|
+
### 2.2 No module-root `index.ts`
|
|
79
|
+
|
|
80
|
+
This is the one absolute rule:
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
❌ src/app/orders/index.ts ← banned
|
|
84
|
+
✅ src/app/orders/services/index.ts ← good (sub-folder barrel)
|
|
85
|
+
✅ src/app/orders/utils/index.ts ← good
|
|
86
|
+
✅ src/app/orders/types/index.ts ← good
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
See `skills/code-standards/SKILL.md` § 2.2 for the reasoning.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## 3. Public vs private surface
|
|
94
|
+
|
|
95
|
+
### 3.1 Public (importable from other modules)
|
|
96
|
+
|
|
97
|
+
- `src/app/<module>/services` (the sub-folder barrel)
|
|
98
|
+
- `src/app/<module>/utils` (the sub-folder barrel)
|
|
99
|
+
- `src/app/<module>/types` (the sub-folder barrel)
|
|
100
|
+
- `src/app/<module>/resources/<noun>.resource.ts`
|
|
101
|
+
- `src/app/<module>/models/<noun>` (the model folder — for `BelongsTo("Name")` registrations and typed references)
|
|
102
|
+
- `src/app/<module>/errors/<noun>-<reason>.error.ts`
|
|
103
|
+
|
|
104
|
+
### 3.2 Private (not importable from other modules)
|
|
105
|
+
|
|
106
|
+
- `src/app/<module>/controllers/*` — controllers serve HTTP; nobody else should call them
|
|
107
|
+
- `src/app/<module>/repositories/*` — repositories are a service-internal detail; other modules go through services
|
|
108
|
+
- `src/app/<module>/schema/*` — schemas belong to the endpoint that uses them
|
|
109
|
+
- `src/app/<module>/routes.ts` — registers routes for the framework, nobody imports it
|
|
110
|
+
|
|
111
|
+
### 3.3 Enforce with ESLint
|
|
112
|
+
|
|
113
|
+
Add `eslint-plugin-import/no-restricted-paths` rules:
|
|
114
|
+
|
|
115
|
+
```js
|
|
116
|
+
// eslint.config.js — proposed addition
|
|
117
|
+
{
|
|
118
|
+
rules: {
|
|
119
|
+
"import/no-restricted-paths": ["error", {
|
|
120
|
+
zones: [
|
|
121
|
+
{ target: "src/app/*/controllers", from: "src/app/*/controllers", except: ["./"] },
|
|
122
|
+
{ target: "src/app", from: "src/app/*/repositories", except: ["./*/services"] },
|
|
123
|
+
{ target: "src/app", from: "src/app/*/schema", except: ["./*/controllers"] },
|
|
124
|
+
],
|
|
125
|
+
}],
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The exact rule shape depends on the eslint config — but the principle is: cross-module imports go through services / utils / types / resources / models / errors, never through the private folders.
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## 4. No reaching across module internals
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
// ✅ public surface — the module's barrel
|
|
138
|
+
import { listUsersService } from "app/users/services";
|
|
139
|
+
|
|
140
|
+
// ✅ public — a typed type
|
|
141
|
+
import { type User } from "app/users/types";
|
|
142
|
+
|
|
143
|
+
// ✅ public — model class for relations
|
|
144
|
+
import { User } from "app/users/models/user/user.model";
|
|
145
|
+
|
|
146
|
+
// ❌ private — repository is service-internal
|
|
147
|
+
import { usersRepository } from "app/users/repositories/users.repository";
|
|
148
|
+
|
|
149
|
+
// ❌ private — controller is HTTP-internal
|
|
150
|
+
import { listUsersController } from "app/users/controllers/list-users.controller";
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
If you find yourself needing another module's internals, the boundary is wrong. Either:
|
|
154
|
+
|
|
155
|
+
- The internal API should be promoted to public (add a service that exposes it).
|
|
156
|
+
- The functionality belongs in `shared/`.
|
|
157
|
+
- The two modules should be merged.
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## 5. Zero circular dependencies
|
|
162
|
+
|
|
163
|
+
### 5.1 What a cycle looks like
|
|
164
|
+
|
|
165
|
+
`app/orders` imports `app/users/services`, and `app/users` imports `app/orders/services`. Even one level deep, this is a cycle.
|
|
166
|
+
|
|
167
|
+
### 5.2 Why it's banned
|
|
168
|
+
|
|
169
|
+
- Runtime import-order bugs that only surface in production
|
|
170
|
+
- Refactor blast radius: changing either module forces changes in both
|
|
171
|
+
- Module-level reasoning breaks down — you can't think about either in isolation
|
|
172
|
+
|
|
173
|
+
### 5.3 The fix
|
|
174
|
+
|
|
175
|
+
Almost always: extract the shared concept into a third module.
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
❌ orders ↔ users (cycle)
|
|
179
|
+
✅ orders → users-shared ← shared types / events
|
|
180
|
+
users → users-shared
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Or, if the shared concept is purely cross-cutting (not a domain), it goes to `app/shared/`.
|
|
184
|
+
|
|
185
|
+
### 5.4 Detect in CI
|
|
186
|
+
|
|
187
|
+
`eslint-plugin-import/no-cycle` catches cycles at lint time. Enable it.
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## 6. Inter-module communication
|
|
192
|
+
|
|
193
|
+
### 6.1 Direct call — when you need a result
|
|
194
|
+
|
|
195
|
+
`app/orders` needs the user's name to format an order confirmation: import `getUserService` from `app/users/services`, call it, use the result.
|
|
196
|
+
|
|
197
|
+
This is the default. Most inter-module communication is direct calls.
|
|
198
|
+
|
|
199
|
+
### 6.2 Event bus — when you don't need a result
|
|
200
|
+
|
|
201
|
+
`app/orders` finishes placing an order; `app/notifications` wants to send a confirmation email; `app/analytics` wants to log the event. None of those need to block the order-placement flow, and orders shouldn't know about either.
|
|
202
|
+
|
|
203
|
+
Use `@mongez/events`:
|
|
204
|
+
|
|
205
|
+
```typescript
|
|
206
|
+
// ✅ orders module emits
|
|
207
|
+
import { events } from "@mongez/events";
|
|
208
|
+
events.trigger("order.placed", { orderId, userId });
|
|
209
|
+
|
|
210
|
+
// ✅ notifications module listens
|
|
211
|
+
events.subscribe("order.placed", async ({ orderId, userId }) => {
|
|
212
|
+
await sendOrderConfirmationEmail(userId, orderId);
|
|
213
|
+
});
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### 6.3 Pick rule
|
|
217
|
+
|
|
218
|
+
- "I need a result" → direct service call
|
|
219
|
+
- "Interested parties may want to know" → event
|
|
220
|
+
- "I need both" → direct call for the result + emit event for the others
|
|
221
|
+
|
|
222
|
+
### 6.4 Never shared mutable globals
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
// ❌
|
|
226
|
+
// app/orders/state.ts
|
|
227
|
+
export let currentOrderCount = 0;
|
|
228
|
+
|
|
229
|
+
// app/dashboard/services/get-stats.service.ts
|
|
230
|
+
import { currentOrderCount } from "app/orders/state";
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Top-level mutable exports across modules are the worst kind of coupling — invisible, untyped, untestable. Always go through a service or an event.
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## 7. Where shared code lives
|
|
238
|
+
|
|
239
|
+
| If the code is... | Put it in |
|
|
240
|
+
| --------------------------------------- | --------------------------------------------- |
|
|
241
|
+
| Used by 2+ modules, stable, cross-domain | `src/app/shared/` |
|
|
242
|
+
| Used by 2+ modules, domain-specific | Extract a new domain module |
|
|
243
|
+
| Used by exactly 1 module | Stays inside that module's `utils/` |
|
|
244
|
+
| Third-party integration glue (SDK wrappers) | `src/app/shared/` (or a dedicated module) |
|
|
245
|
+
| Build-time config | `src/config/` (one file per concern) |
|
|
246
|
+
|
|
247
|
+
### 7.1 Promotion to `shared/` is a deliberate choice
|
|
248
|
+
|
|
249
|
+
If a util is duplicated across two modules, that's a signal — but the right answer isn't always "move to shared". Sometimes the two utils have drifted intentionally; sometimes one module is about to absorb the other. Promote only when the abstraction is genuinely stable.
|
|
250
|
+
|
|
251
|
+
### 7.2 `shared/` is not a junkyard
|
|
252
|
+
|
|
253
|
+
Every file in `shared/` has a clear single purpose. If `shared/utils/` grows past ~20 files, split it by concern (`shared/strings/`, `shared/dates/`, etc.) before it becomes the codebase's attic.
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
## 8. Adding a new module — the checklist
|
|
258
|
+
|
|
259
|
+
When you create a new module:
|
|
260
|
+
|
|
261
|
+
1. **Pick the noun.** Single domain concept. Lowercase, kebab-case, plural.
|
|
262
|
+
2. **Create the folder.** `mkdir src/app/<noun>`.
|
|
263
|
+
3. **Add `README.md`** at the module root. One paragraph: what the module owns, who owns the code, public surface.
|
|
264
|
+
4. **Scaffold only what you need.** Don't pre-create `events/` and `errors/` if you won't use them today.
|
|
265
|
+
5. **Add sub-folder barrels** only where you have 2+ files. A single service doesn't need a `services/index.ts` yet — but it will the moment a second service lands.
|
|
266
|
+
6. **Wire `routes.ts`** for any HTTP endpoints, register events for any emitted signals.
|
|
267
|
+
7. **Verify no module-root `index.ts`** was created.
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
|
|
271
|
+
## 9. Review checklist
|
|
272
|
+
|
|
273
|
+
Before merging a change that adds, restructures, or imports across modules:
|
|
274
|
+
|
|
275
|
+
- [ ] New module is named after a single domain noun
|
|
276
|
+
- [ ] Standard folder layout from § 2 (only the folders the module needs)
|
|
277
|
+
- [ ] No module-root `index.ts` created
|
|
278
|
+
- [ ] Sub-folder barrels exist for `services/`, `utils/`, `types/` where applicable
|
|
279
|
+
- [ ] No imports from another module's `controllers/`, `repositories/`, `schema/`
|
|
280
|
+
- [ ] No circular imports between modules (ESLint `no-cycle` clean)
|
|
281
|
+
- [ ] Cross-module communication uses direct service call or `@mongez/events` — never shared mutable globals
|
|
282
|
+
- [ ] Shared code that's used by 2+ modules lives in `src/app/shared/`, not duplicated
|
|
283
|
+
- [ ] Module has a `README.md` describing purpose, public surface, and ownership
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: observability-and-resilience
|
|
3
|
+
description: 'How this project stays debuggable in production and survives flaky dependencies — `log.<level>(module, action, message, context)` from `@warlock.js/logger` with `X-Request-Id` propagation (already shipped via core middleware), structured metrics naming `<area>.<noun>.<unit>`, health endpoints (`/health/live` for liveness vs `/health/ready` for readiness), timeouts on every external call (`@warlock.js/http` `timeout` per Http instance / per request), retries with exponential backoff for idempotent ops only (never auto-retry POST without an idempotency key), idempotency keys for side effects (payments, emails, webhook deliveries), circuit breakers for flaky deps, graceful shutdown via `log.configure({ autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"] })`, N+1 detection, streaming for large payloads. Triggers: making an HTTP / AI / queue / DB call; adding a timeout or retry; designing an idempotency key for a side effect; instrumenting metrics; adding a health check; tracing a request across services; user asks "timeout", "retry policy", "idempotency", "request id", "tracing", "graceful shutdown", "health check", "circuit breaker", "N+1 query", "caching strategy", "metrics naming"; touching `@warlock.js/http` / `@warlock.js/scheduler` / `@warlock.js/ai` call sites. Skip: log call signature (load `skills/code-standards/SKILL.md` § 8); secrets in logs (load `skills/security-baseline/SKILL.md` § 8); pure-function refactors with no external I/O; cache driver setup (load `@warlock.js/cache` skills); HTTP client config in depth (load `@warlock.js/http` skills).'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Observability & resilience
|
|
7
|
+
|
|
8
|
+
**Status:** Stable
|
|
9
|
+
**Applies to:** Every external call, every long-running operation, every production process.
|
|
10
|
+
|
|
11
|
+
Without observability you debug by guessing. Without resilience one slow dependency takes the whole service down. These rules cost a few extra lines per call site and save days of incident response.
|
|
12
|
+
|
|
13
|
+
> **Sub-agent rule:** Before making any external call or designing a long-running operation, read this file.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 1. Request ID propagation
|
|
18
|
+
|
|
19
|
+
### 1.1 One ID per request
|
|
20
|
+
|
|
21
|
+
The framework's `@warlock.js/core` HTTP middleware already attaches an `X-Request-Id` header — honoured if the client sent one, generated if not. The same id is echoed back in the response and available to every log call within the request's lifetime.
|
|
22
|
+
|
|
23
|
+
### 1.2 Include it in every log line
|
|
24
|
+
|
|
25
|
+
Inside a request, pass the request id as part of the log context:
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
// ✅
|
|
29
|
+
log.info("orders", "order-placed", "Order created", {
|
|
30
|
+
orderId,
|
|
31
|
+
userId,
|
|
32
|
+
requestId: request.id,
|
|
33
|
+
});
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Without it, debugging a production incident is archaeology — you can't correlate the upstream log line with the downstream failure.
|
|
37
|
+
|
|
38
|
+
### 1.3 Propagate downstream
|
|
39
|
+
|
|
40
|
+
When this service calls another service or a third-party API, forward the request id as `X-Request-Id` on the outgoing call. The full trace is what makes distributed debugging possible.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 2. Timeouts
|
|
45
|
+
|
|
46
|
+
**Every external call has an explicit timeout.** No exceptions.
|
|
47
|
+
|
|
48
|
+
### 2.1 HTTP — `@warlock.js/http`
|
|
49
|
+
|
|
50
|
+
Set a default at the `Http` instance level; override per request when the endpoint legitimately needs more.
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
// ✅ instance-level default
|
|
54
|
+
const stripeClient = new Http({
|
|
55
|
+
baseURL: "https://api.stripe.com",
|
|
56
|
+
timeout: 10_000, // 10s — enough for most calls, short enough to fail fast
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ✅ per-request override for a known-slow call
|
|
60
|
+
await stripeClient.post("/v1/payouts/preview", body, { timeout: 30_000 });
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### 2.2 AI calls
|
|
64
|
+
|
|
65
|
+
AI calls run longer than typical HTTP. Use the per-provider defaults from `@warlock.js/ai` adapters and override per call when streaming a known-large completion. Never leave AI calls without a timeout — a hung model = a wedged worker.
|
|
66
|
+
|
|
67
|
+
### 2.3 Database queries
|
|
68
|
+
|
|
69
|
+
Driver-level timeout (set in `config/database.ts`) + per-statement timeout for known-expensive queries. A 30-second query lock kills concurrency under load.
|
|
70
|
+
|
|
71
|
+
### 2.4 Queue ops
|
|
72
|
+
|
|
73
|
+
Set the visibility timeout to match the expected processing time of the message — long enough that the handler finishes, short enough that a crashed worker doesn't park the message for hours.
|
|
74
|
+
|
|
75
|
+
### 2.5 Why this matters
|
|
76
|
+
|
|
77
|
+
A dependency that hangs without timing out is worse than a dependency that errors. Errors are visible; hangs eat workers silently until the pool is empty and the service stops responding.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## 3. Retries
|
|
82
|
+
|
|
83
|
+
### 3.1 Retry only idempotent ops
|
|
84
|
+
|
|
85
|
+
- **Safe to retry**: `GET`, `PUT`, `DELETE`, reads, lookups, queries
|
|
86
|
+
- **Never auto-retry**: `POST` without an idempotency key, queue ops without dedup, payment charges
|
|
87
|
+
|
|
88
|
+
A naive retry on a failed payment charge double-charges the customer. Don't.
|
|
89
|
+
|
|
90
|
+
### 3.2 Exponential backoff with jitter
|
|
91
|
+
|
|
92
|
+
A retry storm against a recovering service brings it down again. Backoff with jitter spreads the load:
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
attempt 1 → fail
|
|
96
|
+
wait 100ms ± 50ms
|
|
97
|
+
attempt 2 → fail
|
|
98
|
+
wait 250ms ± 100ms
|
|
99
|
+
attempt 3 → fail
|
|
100
|
+
wait 600ms ± 200ms
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### 3.3 Bounded
|
|
104
|
+
|
|
105
|
+
Max retries: small (3–5). Past that, surface the failure to the caller and let them decide. Infinite-retry-with-backoff is just a slower hang.
|
|
106
|
+
|
|
107
|
+
### 3.4 Retry budget
|
|
108
|
+
|
|
109
|
+
When a dependency is clearly down (sustained failure rate > 50% over 30s), stop retrying entirely and fast-fail. See § 5 circuit breakers.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## 4. Idempotency keys
|
|
114
|
+
|
|
115
|
+
### 4.1 What needs one
|
|
116
|
+
|
|
117
|
+
Any operation whose accidental retry would cause real-world harm:
|
|
118
|
+
|
|
119
|
+
- Payment charges, refunds, payouts
|
|
120
|
+
- Outbound emails, SMS, push notifications
|
|
121
|
+
- Webhook deliveries
|
|
122
|
+
- Third-party POSTs that mutate state
|
|
123
|
+
|
|
124
|
+
### 4.2 Key generation
|
|
125
|
+
|
|
126
|
+
- **Server-internal calls**: generate the key at the call site (UUID / nanoid), store it.
|
|
127
|
+
- **Client-originated mutations** (e.g. "place order" from a mobile app): the client generates the key and sends it as a header (`Idempotency-Key`); the server stores `key → result`.
|
|
128
|
+
|
|
129
|
+
### 4.3 Storage
|
|
130
|
+
|
|
131
|
+
Persist `key → result` with a TTL longer than your retry window (24h is typical). On retry with the same key, return the stored result without re-executing.
|
|
132
|
+
|
|
133
|
+
### 4.4 Pattern
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
// ✅
|
|
137
|
+
export async function chargePaymentService(input, idempotencyKey) {
|
|
138
|
+
const cached = await idempotencyRepository.find({ key: idempotencyKey });
|
|
139
|
+
if (cached) {
|
|
140
|
+
return cached.result;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const result = await stripeClient.post("/v1/charges", input);
|
|
144
|
+
await idempotencyRepository.create({ key: idempotencyKey, result, expiresAt: in24h });
|
|
145
|
+
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## 5. Circuit breakers
|
|
153
|
+
|
|
154
|
+
### 5.1 When to add one
|
|
155
|
+
|
|
156
|
+
For any dependency that's flaky enough that retries alone aren't enough — typically third-party APIs, edge services, anything outside your operational control.
|
|
157
|
+
|
|
158
|
+
### 5.2 States
|
|
159
|
+
|
|
160
|
+
- **Closed** — normal operation, calls pass through.
|
|
161
|
+
- **Open** — failure rate exceeded threshold; calls fail fast without hitting the dependency.
|
|
162
|
+
- **Half-open** — after a cooldown, allow a few calls through to probe recovery.
|
|
163
|
+
|
|
164
|
+
### 5.3 Fallback behaviour
|
|
165
|
+
|
|
166
|
+
When the breaker is open, the call site needs a plan:
|
|
167
|
+
|
|
168
|
+
- Return a cached value (if the data is cacheable)
|
|
169
|
+
- Queue for later (if the operation can wait)
|
|
170
|
+
- Fail-fast to the user (if neither — at least the user knows immediately)
|
|
171
|
+
|
|
172
|
+
Never silently swallow.
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## 6. Health checks
|
|
177
|
+
|
|
178
|
+
### 6.1 Two endpoints, two semantics
|
|
179
|
+
|
|
180
|
+
- **`/health/live`** — liveness. Is the process alive? Almost always returns 200; the only way it returns non-200 is if it's about to crash anyway. Used by the orchestrator to decide whether to kill and restart.
|
|
181
|
+
- **`/health/ready`** — readiness. Can the process serve traffic? Checks DB connectivity, queue connectivity, warm-up completion. Used by the load balancer to decide whether to route traffic.
|
|
182
|
+
|
|
183
|
+
### 6.2 Don't conflate them
|
|
184
|
+
|
|
185
|
+
Returning 503 from `/health/live` because the DB is down causes the orchestrator to restart the process — which doesn't fix a DB problem and adds restart-loop chaos to an outage. Use `/health/ready` for "I can't serve right now" so the LB drains traffic without the orchestrator interfering.
|
|
186
|
+
|
|
187
|
+
### 6.3 No internal details leaked
|
|
188
|
+
|
|
189
|
+
Health endpoints return a status code, not internal version info, env names, dependency hostnames, or stack traces. Those are information leaks under a different name.
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## 7. Metrics
|
|
194
|
+
|
|
195
|
+
### 7.1 Three primitive types
|
|
196
|
+
|
|
197
|
+
- **Counter** — things that happened, monotonically increasing (`orders.placed.total`, `auth.login.failed.total`)
|
|
198
|
+
- **Gauge** — current value (`queue.depth`, `connections.active`, `cache.size_bytes`)
|
|
199
|
+
- **Histogram** — distribution (`http.request.duration_ms`, `ai.completion.tokens`)
|
|
200
|
+
|
|
201
|
+
### 7.2 Naming — `<area>.<noun>.<unit>`
|
|
202
|
+
|
|
203
|
+
Match the log `module` / `action` convention. The metric `orders.placed.total` corresponds to the log call `log.success("orders", "order-placed", ...)`. Symmetry across observability surfaces makes incidents debuggable.
|
|
204
|
+
|
|
205
|
+
### 7.3 Cardinality discipline
|
|
206
|
+
|
|
207
|
+
**Never** include high-cardinality values as labels: user-id, request-id, order-id, email. Each unique value creates a new metric series; a million users = a million series = your monitoring backend's invoice goes vertical.
|
|
208
|
+
|
|
209
|
+
Use logs for high-cardinality data, metrics for aggregates.
|
|
210
|
+
|
|
211
|
+
### 7.4 What to instrument
|
|
212
|
+
|
|
213
|
+
Start with the four golden signals:
|
|
214
|
+
|
|
215
|
+
- **Latency** — `http.request.duration_ms` histogram, by route
|
|
216
|
+
- **Traffic** — `http.requests.total` counter, by route + status
|
|
217
|
+
- **Errors** — `http.requests.errors.total` counter, by route + error class
|
|
218
|
+
- **Saturation** — queue depth, connection pool usage, memory
|
|
219
|
+
|
|
220
|
+
Add business metrics on top (`orders.placed.total`, `revenue.cents.total` — taking care with cardinality).
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## 8. Graceful shutdown
|
|
225
|
+
|
|
226
|
+
### 8.1 The sequence
|
|
227
|
+
|
|
228
|
+
SIGTERM (or SIGINT) →
|
|
229
|
+
|
|
230
|
+
1. Stop accepting new requests / new queue messages (close the listeners).
|
|
231
|
+
2. Finish in-flight work with a hard timeout (30s typical).
|
|
232
|
+
3. Flush logs.
|
|
233
|
+
4. Close DB / external connections cleanly.
|
|
234
|
+
5. Exit 0.
|
|
235
|
+
|
|
236
|
+
### 8.2 Logger flush
|
|
237
|
+
|
|
238
|
+
`@warlock.js/logger` covers step 3 when you configure it correctly:
|
|
239
|
+
|
|
240
|
+
```typescript
|
|
241
|
+
// ✅ config/log.ts (or equivalent boot file)
|
|
242
|
+
log.configure({
|
|
243
|
+
channels: [/* ... */],
|
|
244
|
+
autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"],
|
|
245
|
+
});
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Without `autoFlushOn`, buffered log entries are lost on shutdown — exactly when you most want them.
|
|
249
|
+
|
|
250
|
+
### 8.3 The hard timeout matters
|
|
251
|
+
|
|
252
|
+
If in-flight work doesn't finish in 30s, kill it anyway. The alternative is hanging forever waiting for one stuck request, which makes deploys / restarts unreliable.
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## 9. Performance defaults
|
|
257
|
+
|
|
258
|
+
These belong here because performance failures present as resilience failures — a slow query starves the pool, a missing index turns a 10ms call into a 5-second call, an unbounded list returns 50k rows and OOMs the response serializer.
|
|
259
|
+
|
|
260
|
+
### 9.1 N+1 queries are bugs, not warnings
|
|
261
|
+
|
|
262
|
+
A list endpoint that fires one query per item is a bug. Either eager-load relations (Cascade's `.with("relation")`) or batch-fetch the related ids in a single query and join in-process.
|
|
263
|
+
|
|
264
|
+
A code review that lets an N+1 through is doing its job poorly. Add it to the review checklist (already in `skills/api-design/SKILL.md`).
|
|
265
|
+
|
|
266
|
+
### 9.2 Pagination always
|
|
267
|
+
|
|
268
|
+
See `skills/api-design/SKILL.md` § 3. No unbounded list ever, even if today's dataset is 12 rows.
|
|
269
|
+
|
|
270
|
+
### 9.3 Stream large payloads
|
|
271
|
+
|
|
272
|
+
For responses > 1MB or uploads > 1MB, stream end-to-end. Never buffer a 50MB file into memory to send it.
|
|
273
|
+
|
|
274
|
+
### 9.4 Cache invalidation strategy
|
|
275
|
+
|
|
276
|
+
Pick one and stick to it per surface:
|
|
277
|
+
|
|
278
|
+
- **TTL** — simplest; tolerable staleness for a fixed window. Good for read-mostly catalog data.
|
|
279
|
+
- **Write-through** — invalidate on the corresponding write. Good for user-owned data where staleness is visible.
|
|
280
|
+
- **Event-driven** — invalidate on a published event. Good across multiple services.
|
|
281
|
+
|
|
282
|
+
`repository.listCached(...)` exists for hot read paths — use it where the dataset changes rarely. Invalidate on the corresponding write through the repository's hooks.
|
|
283
|
+
|
|
284
|
+
### 9.5 No sync I/O on the request path
|
|
285
|
+
|
|
286
|
+
`readFileSync`, `execSync`, sync DB drivers — all banned in handler code. They block the event loop and crater throughput under load.
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
## 10. Review checklist
|
|
291
|
+
|
|
292
|
+
Before merging a change that touches an external call, long-running op, or production wiring:
|
|
293
|
+
|
|
294
|
+
- [ ] Request id propagated in every log line within the request lifecycle
|
|
295
|
+
- [ ] External call has an explicit timeout (HTTP, AI, queue, DB)
|
|
296
|
+
- [ ] Retries only on idempotent ops; non-idempotent ops have an idempotency key
|
|
297
|
+
- [ ] Idempotency keys persisted with a TTL longer than the retry window
|
|
298
|
+
- [ ] Circuit breaker considered for any flaky third-party dependency
|
|
299
|
+
- [ ] Health endpoints split — `/health/live` separate from `/health/ready`
|
|
300
|
+
- [ ] No high-cardinality values (user-id, request-id) as metric labels
|
|
301
|
+
- [ ] Metric names follow `<area>.<noun>.<unit>`, mirror log `module.action`
|
|
302
|
+
- [ ] `log.configure({ autoFlushOn: ["SIGINT", "SIGTERM", "beforeExit"] })` set
|
|
303
|
+
- [ ] No N+1 introduced — relations eager-loaded or batched
|
|
304
|
+
- [ ] No unbounded list endpoint
|
|
305
|
+
- [ ] No sync I/O on the request path
|
|
306
|
+
- [ ] Cache invalidation strategy declared for any new cache surface
|