@warlock.js/core 5.24.0 → 5.26.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.
Files changed (256) hide show
  1. package/CHANGELOG.md +64 -1
  2. package/bin/warlock.js +0 -0
  3. package/esm/application/application.d.mts +21 -0
  4. package/esm/application/application.d.mts.map +1 -1
  5. package/esm/application/application.mjs +29 -1
  6. package/esm/application/application.mjs.map +1 -1
  7. package/esm/application/boot-signal.d.mts +2 -1
  8. package/esm/application/boot-signal.d.mts.map +1 -1
  9. package/esm/application/boot-signal.mjs.map +1 -1
  10. package/esm/application/index.d.mts +1 -0
  11. package/esm/application/index.mjs +1 -0
  12. package/esm/application/roles.d.mts +72 -0
  13. package/esm/application/roles.d.mts.map +1 -0
  14. package/esm/application/roles.mjs +124 -0
  15. package/esm/application/roles.mjs.map +1 -0
  16. package/esm/cli/commands/add.command.mjs.map +1 -1
  17. package/esm/cli/commands/create-database.command.mjs.map +1 -1
  18. package/esm/cli/commands/dev-server.command.mjs.map +1 -1
  19. package/esm/cli/commands/doctor/boot-for-diagnostics.mjs.map +1 -1
  20. package/esm/cli/commands/doctor/checks/index.mjs +2 -0
  21. package/esm/cli/commands/doctor/checks/index.mjs.map +1 -1
  22. package/esm/cli/commands/doctor/checks/locales-ts-deprecated.check.mjs +170 -0
  23. package/esm/cli/commands/doctor/checks/locales-ts-deprecated.check.mjs.map +1 -0
  24. package/esm/cli/commands/doctor/doctor-command.action.mjs +7 -2
  25. package/esm/cli/commands/doctor/doctor-command.action.mjs.map +1 -1
  26. package/esm/cli/commands/doctor.command.mjs +5 -0
  27. package/esm/cli/commands/doctor.command.mjs.map +1 -1
  28. package/esm/cli/commands/drop-tables.command.mjs.map +1 -1
  29. package/esm/cli/commands/generate/generators/module.generator.mjs +1 -12
  30. package/esm/cli/commands/generate/generators/module.generator.mjs.map +1 -1
  31. package/esm/cli/commands/generate/utils/path-resolver.mjs.map +1 -1
  32. package/esm/cli/commands/migrate.command.mjs.map +1 -1
  33. package/esm/cli/commands/routes.command.mjs.map +1 -1
  34. package/esm/cli/commands/seed.command.mjs.map +1 -1
  35. package/esm/cli/commands/start-production.command.mjs +54 -5
  36. package/esm/cli/commands/start-production.command.mjs.map +1 -1
  37. package/esm/cli/commands/storage-put.action.mjs.map +1 -1
  38. package/esm/cli/commands/storage-put.command.mjs.map +1 -1
  39. package/esm/cli/commands/typings-generator.command.mjs +20 -1
  40. package/esm/cli/commands/typings-generator.command.mjs.map +1 -1
  41. package/esm/cli/commands/update.command.mjs.map +1 -1
  42. package/esm/cli/commands-loader.mjs.map +1 -1
  43. package/esm/config/config-loader.d.mts.map +1 -1
  44. package/esm/config/config-loader.mjs +3 -1
  45. package/esm/config/config-loader.mjs.map +1 -1
  46. package/esm/connectors/connectors-manager.mjs.map +1 -1
  47. package/esm/connectors/database-connector.mjs.map +1 -1
  48. package/esm/connectors/herald-connector.mjs.map +1 -1
  49. package/esm/connectors/http-connector.d.mts.map +1 -1
  50. package/esm/connectors/http-connector.mjs +7 -0
  51. package/esm/connectors/http-connector.mjs.map +1 -1
  52. package/esm/connectors/logger-connector.mjs.map +1 -1
  53. package/esm/connectors/mail-connector.mjs.map +1 -1
  54. package/esm/connectors/types.d.mts +8 -0
  55. package/esm/connectors/types.d.mts.map +1 -1
  56. package/esm/connectors/types.mjs.map +1 -1
  57. package/esm/database/create-database-action.mjs.map +1 -1
  58. package/esm/database/drop-tables-action.mjs.map +1 -1
  59. package/esm/database/migrate-action.mjs +4 -3
  60. package/esm/database/migrate-action.mjs.map +1 -1
  61. package/esm/database/models/database-log/database-log.mjs.map +1 -1
  62. package/esm/database/seed-command-action.mjs +2 -1
  63. package/esm/database/seed-command-action.mjs.map +1 -1
  64. package/esm/dev-server/dependency-graph.mjs.map +1 -1
  65. package/esm/dev-server/development-server.mjs +2 -0
  66. package/esm/dev-server/development-server.mjs.map +1 -1
  67. package/esm/dev-server/files-orchestrator.mjs.map +1 -1
  68. package/esm/dev-server/layer-executor.mjs.map +1 -1
  69. package/esm/dev-server/legacy-locales-warning.mjs +16 -0
  70. package/esm/dev-server/legacy-locales-warning.mjs.map +1 -0
  71. package/esm/dev-server/manifest-manager.mjs.map +1 -1
  72. package/esm/dev-server/module-loader.mjs +26 -5
  73. package/esm/dev-server/module-loader.mjs.map +1 -1
  74. package/esm/dev-server/register-installed-devtools.mjs +47 -0
  75. package/esm/dev-server/register-installed-devtools.mjs.map +1 -0
  76. package/esm/dev-server/run-connector-typings.mjs +28 -0
  77. package/esm/dev-server/run-connector-typings.mjs.map +1 -0
  78. package/esm/dev-server/special-file-patterns.mjs +5 -3
  79. package/esm/dev-server/special-file-patterns.mjs.map +1 -1
  80. package/esm/dev-server/special-files-collector.d.mts +2 -2
  81. package/esm/dev-server/special-files-collector.d.mts.map +1 -1
  82. package/esm/dev-server/special-files-collector.mjs +7 -4
  83. package/esm/dev-server/special-files-collector.mjs.map +1 -1
  84. package/esm/dev-server/translation-keys-sources.mjs +7 -3
  85. package/esm/dev-server/translation-keys-sources.mjs.map +1 -1
  86. package/esm/dev-server/type-generator.mjs +27 -4
  87. package/esm/dev-server/type-generator.mjs.map +1 -1
  88. package/esm/encryption/password.mjs.map +1 -1
  89. package/esm/generations/add-command.action.mjs +3 -1
  90. package/esm/generations/add-command.action.mjs.map +1 -1
  91. package/esm/generations/features/access.feature.mjs +38 -18
  92. package/esm/generations/features/access.feature.mjs.map +1 -1
  93. package/esm/generations/features/ai-anthropic.feature.mjs.map +1 -1
  94. package/esm/generations/features/ai-bedrock.feature.mjs.map +1 -1
  95. package/esm/generations/features/ai-google.feature.mjs.map +1 -1
  96. package/esm/generations/features/ai-ollama.feature.mjs.map +1 -1
  97. package/esm/generations/features/ai-openai.feature.mjs.map +1 -1
  98. package/esm/generations/features/ai-panoptic.feature.mjs.map +1 -1
  99. package/esm/generations/features/ai-tools.feature.mjs.map +1 -1
  100. package/esm/generations/features/ai-workspace.feature.mjs.map +1 -1
  101. package/esm/generations/features/ai.feature.mjs.map +1 -1
  102. package/esm/generations/features/bull-board.feature.mjs +2 -2
  103. package/esm/generations/features/bull-board.feature.mjs.map +1 -1
  104. package/esm/generations/features/devtools.feature.mjs +23 -0
  105. package/esm/generations/features/devtools.feature.mjs.map +1 -0
  106. package/esm/generations/features/herald.feature.mjs.map +1 -1
  107. package/esm/generations/features/image.feature.mjs.map +1 -1
  108. package/esm/generations/features/index.mjs +2 -0
  109. package/esm/generations/features/index.mjs.map +1 -1
  110. package/esm/generations/features/mail.feature.mjs.map +1 -1
  111. package/esm/generations/features/mongodb.feature.mjs.map +1 -1
  112. package/esm/generations/features/notifications.feature.mjs.map +1 -1
  113. package/esm/generations/features/postgres.feature.mjs.map +1 -1
  114. package/esm/generations/features/react-email.feature.mjs.map +1 -1
  115. package/esm/generations/features/react.feature.mjs.map +1 -1
  116. package/esm/generations/features/redis.feature.mjs.map +1 -1
  117. package/esm/generations/features/s3.feature.mjs.map +1 -1
  118. package/esm/generations/features/scheduler.feature.mjs.map +1 -1
  119. package/esm/generations/features/ses.feature.mjs.map +1 -1
  120. package/esm/generations/features/socket.feature.mjs.map +1 -1
  121. package/esm/generations/features/test.feature.mjs +6 -2
  122. package/esm/generations/features/test.feature.mjs.map +1 -1
  123. package/esm/generations/features/types.mjs.map +1 -1
  124. package/esm/generations/features/web.feature.mjs.map +1 -1
  125. package/esm/http/build-rate-limit-options.d.mts +8 -2
  126. package/esm/http/build-rate-limit-options.d.mts.map +1 -1
  127. package/esm/http/build-rate-limit-options.mjs +1 -0
  128. package/esm/http/build-rate-limit-options.mjs.map +1 -1
  129. package/esm/http/database/RequestLog.mjs.map +1 -1
  130. package/esm/http/errors/errors.d.mts +1 -17
  131. package/esm/http/errors/errors.d.mts.map +1 -1
  132. package/esm/http/errors/errors.mjs +1 -20
  133. package/esm/http/errors/errors.mjs.map +1 -1
  134. package/esm/http/index.d.mts +4 -3
  135. package/esm/http/index.mjs +3 -2
  136. package/esm/http/middleware/inject-request-context.d.mts +1 -1
  137. package/esm/http/middleware/inject-request-context.d.mts.map +1 -1
  138. package/esm/http/middleware/inject-request-context.mjs +9 -1
  139. package/esm/http/middleware/inject-request-context.mjs.map +1 -1
  140. package/esm/http/middleware/rate-limit.middleware.d.mts.map +1 -1
  141. package/esm/http/middleware/rate-limit.middleware.mjs +5 -1
  142. package/esm/http/middleware/rate-limit.middleware.mjs.map +1 -1
  143. package/esm/http/request.d.mts +7 -24
  144. package/esm/http/request.d.mts.map +1 -1
  145. package/esm/http/request.mjs +1 -22
  146. package/esm/http/request.mjs.map +1 -1
  147. package/esm/http/response.d.mts +5 -5
  148. package/esm/http/response.d.mts.map +1 -1
  149. package/esm/http/response.mjs +4 -2
  150. package/esm/http/response.mjs.map +1 -1
  151. package/esm/http/server-rebuilt.d.mts +20 -0
  152. package/esm/http/server-rebuilt.d.mts.map +1 -0
  153. package/esm/http/server-rebuilt.mjs +21 -0
  154. package/esm/http/server-rebuilt.mjs.map +1 -0
  155. package/esm/http/tracing/index.mjs +1 -1
  156. package/esm/http/tracing/tracing-dispatcher.d.mts +13 -6
  157. package/esm/http/tracing/tracing-dispatcher.d.mts.map +1 -1
  158. package/esm/http/tracing/tracing-dispatcher.mjs +46 -14
  159. package/esm/http/tracing/tracing-dispatcher.mjs.map +1 -1
  160. package/esm/http/tracing/tracing.type.d.mts +2 -1
  161. package/esm/http/tracing/tracing.type.d.mts.map +1 -1
  162. package/esm/http/types.d.mts +9 -0
  163. package/esm/http/types.d.mts.map +1 -1
  164. package/esm/image/image.mjs.map +1 -1
  165. package/esm/index.d.mts +8 -4
  166. package/esm/index.mjs +8 -3
  167. package/esm/loader/app-module-importer.d.mts +17 -0
  168. package/esm/loader/app-module-importer.d.mts.map +1 -0
  169. package/esm/loader/app-module-importer.mjs +20 -0
  170. package/esm/loader/app-module-importer.mjs.map +1 -0
  171. package/esm/localization/index.mjs +4 -0
  172. package/esm/localization/parse-locale-dictionary.d.mts +23 -0
  173. package/esm/localization/parse-locale-dictionary.d.mts.map +1 -0
  174. package/esm/localization/parse-locale-dictionary.mjs +215 -0
  175. package/esm/localization/parse-locale-dictionary.mjs.map +1 -0
  176. package/esm/localization/register-locale-dictionary.d.mts +12 -0
  177. package/esm/localization/register-locale-dictionary.d.mts.map +1 -0
  178. package/esm/localization/register-locale-dictionary.mjs +58 -0
  179. package/esm/localization/register-locale-dictionary.mjs.map +1 -0
  180. package/esm/logger/logger.mjs.map +1 -1
  181. package/esm/mail/events.d.mts +7 -0
  182. package/esm/mail/events.d.mts.map +1 -1
  183. package/esm/mail/events.mjs +7 -0
  184. package/esm/mail/events.mjs.map +1 -1
  185. package/esm/mail/send-mail.d.mts.map +1 -1
  186. package/esm/mail/send-mail.mjs +23 -2
  187. package/esm/mail/send-mail.mjs.map +1 -1
  188. package/esm/mail/types.d.mts +5 -1
  189. package/esm/mail/types.d.mts.map +1 -1
  190. package/esm/mail/types.mjs.map +1 -1
  191. package/esm/manifest/manifest-manager.mjs.map +1 -1
  192. package/esm/production/build-contributions.mjs +19 -3
  193. package/esm/production/build-contributions.mjs.map +1 -1
  194. package/esm/production/production-builder.mjs +39 -10
  195. package/esm/production/production-builder.mjs.map +1 -1
  196. package/esm/production/production-supervisor.mjs +6 -0
  197. package/esm/production/production-supervisor.mjs.map +1 -1
  198. package/esm/production/resolve-build-config.d.mts +1 -1
  199. package/esm/production/resolve-build-config.mjs +15 -0
  200. package/esm/production/resolve-build-config.mjs.map +1 -1
  201. package/esm/production/route-registration-child.mjs +14 -2
  202. package/esm/production/route-registration-child.mjs.map +1 -1
  203. package/esm/production/route-registration-snapshot.mjs +9 -2
  204. package/esm/production/route-registration-snapshot.mjs.map +1 -1
  205. package/esm/resource/resource-field-builder.mjs +1 -1
  206. package/esm/resource/resource-field-builder.mjs.map +1 -1
  207. package/esm/resource/resource.d.mts +5 -0
  208. package/esm/resource/resource.d.mts.map +1 -1
  209. package/esm/resource/resource.mjs +17 -6
  210. package/esm/resource/resource.mjs.map +1 -1
  211. package/esm/router/router.d.mts.map +1 -1
  212. package/esm/router/router.mjs +1 -0
  213. package/esm/router/router.mjs.map +1 -1
  214. package/esm/router/types.d.mts +10 -1
  215. package/esm/router/types.d.mts.map +1 -1
  216. package/esm/tests/index.d.mts +3 -2
  217. package/esm/tests/index.mjs +3 -2
  218. package/esm/tests/start-http-development-server.d.mts +10 -0
  219. package/esm/tests/start-http-development-server.d.mts.map +1 -1
  220. package/esm/tests/start-http-development-server.mjs +16 -2
  221. package/esm/tests/start-http-development-server.mjs.map +1 -1
  222. package/esm/tests/vitest-setup.d.mts +16 -1
  223. package/esm/tests/vitest-setup.d.mts.map +1 -1
  224. package/esm/tests/vitest-setup.mjs +20 -2
  225. package/esm/tests/vitest-setup.mjs.map +1 -1
  226. package/esm/use-cases/use-case-events.mjs.map +1 -1
  227. package/esm/utils/is-test-file.mjs +15 -0
  228. package/esm/utils/is-test-file.mjs.map +1 -0
  229. package/esm/utils/normalized-path.d.mts +3 -1
  230. package/esm/utils/normalized-path.d.mts.map +1 -1
  231. package/esm/utils/normalized-path.mjs +4 -2
  232. package/esm/utils/normalized-path.mjs.map +1 -1
  233. package/esm/utils/paths.mjs.map +1 -1
  234. package/esm/validation/plugins/database-plugin.mjs.map +1 -1
  235. package/esm/validation/validateAll.mjs +57 -4
  236. package/esm/validation/validateAll.mjs.map +1 -1
  237. package/esm/warlock-config/default-configurations.mjs +1 -0
  238. package/esm/warlock-config/default-configurations.mjs.map +1 -1
  239. package/esm/warlock-config/types.d.mts +7 -0
  240. package/esm/warlock-config/types.d.mts.map +1 -1
  241. package/esm/warlock-config/warlock-config.manager.mjs.map +1 -1
  242. package/llms-full.txt +382 -214
  243. package/llms.txt +1 -1
  244. package/package.json +23 -19
  245. package/skills/README.md +1 -1
  246. package/skills/configure-app/SKILL.md +2 -0
  247. package/skills/create-controller/SKILL.md +4 -0
  248. package/skills/define-resource/SKILL.md +4 -0
  249. package/skills/request-tracing/SKILL.md +38 -15
  250. package/skills/run-app/SKILL.md +71 -34
  251. package/skills/send-mail/SKILL.md +55 -25
  252. package/skills/test-http/SKILL.md +28 -9
  253. package/skills/test-service/SKILL.md +61 -40
  254. package/skills/use-localization/SKILL.md +39 -26
  255. package/skills/warlock-conventions/SKILL.md +26 -18
  256. package/skills/write-cli-command/SKILL.md +50 -45
package/llms-full.txt CHANGED
@@ -618,8 +618,8 @@ export class UsersRestful extends Restful<User> implements RouteResource {
618
618
  public validation = {
619
619
  create: {
620
620
  schema: v.object({
621
- name: v.string().required().min(2),
622
- email: v.email().required(),
621
+ name: v.string().min(2),
622
+ email: v.email(),
623
623
  }),
624
624
  },
625
625
  update: {
@@ -1197,6 +1197,8 @@ Same shape — TS validates the union; runtime picks the `default` key.
1197
1197
  - `socket.adapter` — adapter factory for cross-server broadcast.
1198
1198
  - `cache.silenceSingleServerWarning`, `storage.silenceSingleServerWarning`, `socket.silenceSingleServerWarning` — silence the production warnings for in-memory cache, local storage, and adapter-less sockets.
1199
1199
 
1200
+ `http.modelValidationErrorStatus` sets the status for a Cascade model-write validation failure (a model failing its own schema on save). It defaults to `500` with an opaque body, because request input is already validated with a `422` before the model is touched. Set a `4xx` (e.g. `422`) to treat it as a client error instead; the response then carries `{ errors }`.
1201
+
1200
1202
  ## Per-env knobs
1201
1203
 
1202
1204
  For one-off switches gated on environment, the canonical pattern is `Application.isProduction` (also `isDevelopment`, `isTest`):
@@ -1380,6 +1382,10 @@ Two pieces:
1380
1382
 
1381
1383
  If validation fails, the framework returns a 400 with an `errors` payload and your handler never runs.
1382
1384
 
1385
+ ### Validating route params
1386
+
1387
+ `handler.validation = { params: v.object({ id: v.int().min(1) }) }` validates path segments (e.g. `/posts/:id`). Number and boolean validators convert the string segments; the app's schema object is never changed. Parsed values replace `request.params` (`request.input("id")` is a number) and merge into `request.validated()`, with params winning over body fields. Failures answer like a `schema` failure.
1388
+
1383
1389
  ### Typing a guarded handler
1384
1390
 
1385
1391
  Routes behind `authMiddleware` need `request.locals.user` typed. Project conventions add a `GuardedRequest<TSchema>` (overriding `locals` to `RequestLocals & { user: User }`) and a paired `GuardedRequestHandler<TSchema>` alias in `app/auth/requests/guarded.request`:
@@ -1934,6 +1940,10 @@ export class Product extends Model {
1934
1940
 
1935
1941
  This is convention, not a framework requirement. Controllers/services that import the resource explicitly work the same way.
1936
1942
 
1943
+ ### `id` and Mongo `_id`
1944
+
1945
+ The output `id` falls back to `_id` when the record has no `id`; an explicit `id` always wins. If a collection uses native ObjectIds, set `public primaryKey = "_id"` on the model so `Model.find(hex)` resolves the `id` your resource emits.
1946
+
1937
1947
  ## Output-only rule
1938
1948
 
1939
1949
  Resources map fields. They do not:
@@ -3148,6 +3158,7 @@ type TracingContext = {
3148
3158
  type TracingPhaseInfo = {
3149
3159
  name: string;
3150
3160
  durationMs: number;
3161
+ startedAt?: number; // epoch milliseconds; supplied when the phase started
3151
3162
  attrs?: Record<string, unknown>;
3152
3163
  };
3153
3164
 
@@ -3166,29 +3177,52 @@ type TracingHooks = {
3166
3177
 
3167
3178
  Every verb is optional — a hook that only wants phase spans need not implement
3168
3179
  `onRequestStart`/`onRequestEnd`. Register as many hooks as you like via
3169
- `hooks: TracingHooks[]`; each fires independently.
3180
+ `hooks: TracingHooks[]`; each fires independently. Use `registerTracingHooks()`
3181
+ when the observer is installed by application code rather than static config:
3182
+
3183
+ ```ts
3184
+ import { registerTracingHooks, type TracingHooks } from "@warlock.js/core";
3185
+
3186
+ const hooks: TracingHooks = {
3187
+ onPhase(ctx, phase) {
3188
+ console.log(ctx.traceId, phase.name, phase.startedAt);
3189
+ },
3190
+ };
3191
+
3192
+ const unregister = registerTracingHooks(hooks);
3193
+ // Later, for example during plugin teardown:
3194
+ unregister();
3195
+ ```
3196
+
3197
+ `registerTracingHooks(hooks): () => void` enables tracing immediately. Runtime
3198
+ hooks run after configured hooks; calling the returned unregister function more
3199
+ than once is safe. When a phase did not provide `startedAt`, the dispatcher
3200
+ derives it from its duration before delivering the callback.
3170
3201
 
3171
3202
  ## Phase names
3172
3203
 
3173
3204
  `core` wires five phases into the request lifecycle, in this order, for every
3174
3205
  HTTP request:
3175
3206
 
3176
- | Phase | Fires around |
3177
- | --- | --- |
3178
- | `route.match` | Resolving the incoming path/method to a registered route |
3179
- | `middleware` | Each middleware in the route's chain — one `onPhase` call per middleware, with `attrs: { name, index }` |
3180
- | `validation` | The route's input validation (`v.object(...)` / RESTful resource validation) |
3181
- | `handler` | The route handler itself |
3207
+ | Phase | Fires around |
3208
+ | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
3209
+ | `route.match` | Resolving the incoming path/method to a registered route |
3210
+ | `middleware` | Each middleware in the route's chain — one `onPhase` call per middleware, with `attrs: { name, index }` |
3211
+ | `validation` | The route's input validation (`v.object(...)` / RESTful resource validation) |
3212
+ | `handler` | The route handler itself |
3182
3213
  | `response.write` | The overall request span, closed once the response has settled (success or thrown error) — this is also where `onRequestEnd` fires |
3183
3214
 
3184
3215
  `@warlock.js/web` page requests report through this same `onPhase` surface
3185
3216
  instead of adding a separate hook API. They add three phases:
3186
3217
 
3187
- | Phase | Fires around |
3188
- | --- | --- |
3189
- | `loader` | Each loader level, once per app/layout/page, with `attrs: { level, layoutPath? }` |
3190
- | `render.shell` | Time from render start until React's shell is ready to stream |
3191
- | `stream.end` | The whole streamed response, including every `defer()` value settling |
3218
+ | Phase | Fires around |
3219
+ | ----------------- | --------------------------------------------------------------------------------------------------- |
3220
+ | `loader` | Each loader level, once per app/layout/page, with `attrs: { level, layoutPath? }` |
3221
+ | `render.shell` | Time from render start until React's shell is ready to stream |
3222
+ | `stream.end` | The whole streamed response, including every `defer()` value settling |
3223
+ | `page.middleware` | Page middleware chain; `attrs: { count, outcome }`, where outcome is `next`, `response`, or `error` |
3224
+ | `page.cache` | Page-cache lookup; `attrs: { status }`, where status is `hit`, `miss`, or `bypass` |
3225
+ | `defer.settle` | A deferred key settling; `attrs: { key, status }`, where status is `fulfilled` or `rejected` |
3192
3226
 
3193
3227
  ## Trace id derivation
3194
3228
 
@@ -3282,9 +3316,8 @@ export default {
3282
3316
  - **No OTel dependency, and none planned for `core`.** An OTel (or other
3283
3317
  vendor) bridge is a separate, optional package that subscribes to these
3284
3318
  hooks — never add `@opentelemetry/api` to `core` itself.
3285
- - **`web`'s phases aren't live yet.** Don't register a hook expecting
3286
- `loader`/`render.shell`/`stream.end` calls today; only the five `core`
3287
- phases fire in this release.
3319
+ - **Web phases share the same hook.** Do not create a second page-tracing
3320
+ integration: subscribe with `TracingHooks.onPhase`.
3288
3321
  - **`route` is `undefined` until routing has matched.** There's no path where
3289
3322
  a hook fires before that, but code branching on `ctx.route` for an
3290
3323
  early-failing request (e.g. a 404 with no match) must handle `undefined`.
@@ -3620,11 +3653,11 @@ Boots the framework in dev mode: file watcher, HMR-style module reload, on-disk
3620
3653
 
3621
3654
  ### Flags
3622
3655
 
3623
- | Flag | Default | Purpose |
3624
- | --------------------- | ------- | -------------------------------------------------------------------------------------------------------- |
3656
+ | Flag | Default | Purpose |
3657
+ | --------------------- | ------- | ----------------------------------------------------------------------------------------------------------- |
3625
3658
  | `--fresh, -f` | off | Delete `.warlock/manifest.json` before start — forces a full re-parse from disk. Use after odd boot states. |
3626
- | `--skip-typings, -st` | off | Skip background type generation **for this run**. Overrides `devServer.generateTypings` config. |
3627
- | `--skip-health, -sh` | off | Skip file health checkers **for this run**. Overrides `devServer.healthCheckers` config. |
3659
+ | `--skip-typings, -st` | off | Skip background type generation **for this run**. Overrides `devServer.generateTypings` config. |
3660
+ | `--skip-health, -sh` | off | Skip file health checkers **for this run**. Overrides `devServer.healthCheckers` config. |
3628
3661
 
3629
3662
  When a flag is **not** passed, the corresponding `warlock.config.ts > devServer.*` value applies. When passed, the flag wins.
3630
3663
 
@@ -3632,14 +3665,14 @@ When a flag is **not** passed, the corresponding `warlock.config.ts > devServer.
3632
3665
 
3633
3666
  Once the server is ready, `warlock dev` listens for single keypresses:
3634
3667
 
3635
- | Key | Does |
3636
- | -------- | ---------------------------------------------------------------------------- |
3637
- | `r` | Restart the server on a fresh process. |
3638
- | `c` | Clear the console. |
3639
- | `q` | Graceful shutdown, exit `0` — the same path as `Ctrl+C`. |
3640
- | `h` | Print the shortcuts that are armed right now. |
3668
+ | Key | Does |
3669
+ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
3670
+ | `r` | Restart the server on a fresh process. |
3671
+ | `c` | Clear the console. |
3672
+ | `q` | Graceful shutdown, exit `0` — the same path as `Ctrl+C`. |
3673
+ | `h` | Print the shortcuts that are armed right now. |
3641
3674
  | `u` | **Only while an update notice is showing** — update every `@warlock.js/*` package, install, and restart. See [`update-packages/SKILL.md`](../update-packages/SKILL.md). |
3642
- | `Ctrl+C` | Graceful shutdown, unchanged. |
3675
+ | `Ctrl+C` | Graceful shutdown, unchanged. |
3643
3676
 
3644
3677
  A "restart" is a fresh process (`r` and `u` both use it) — see [The supervisor](#the-supervisor) below for how that works.
3645
3678
 
@@ -3659,7 +3692,7 @@ shell
3659
3692
 
3660
3693
  The supervisor spawns the worker with `stdio: "inherit"` and mirrors its exit code. To restart, the worker shuts down cleanly — freeing the http port — and exits `75`; the supervisor spawns a replacement. **The tree never gets deeper**, however many restarts happen.
3661
3694
 
3662
- The supervisor is chosen in the `dev` command's `preAction`, which runs *before* the preloaders — deliberately, so the supervisor never loads config or starts connectors. A supervisor holding a second database connection open for the session would be a real bug, not a cosmetic one.
3695
+ The supervisor is chosen in the `dev` command's `preAction`, which runs _before_ the preloaders — deliberately, so the supervisor never loads config or starts connectors. A supervisor holding a second database connection open for the session would be a real bug, not a cosmetic one.
3663
3696
 
3664
3697
  Signals: `SIGINT` reaches the worker directly (same process group), so the supervisor ignores it and only notes that the next exit is final — acting on it would exit the parent while the worker was still draining. `SIGTERM` / `SIGHUP` are forwarded explicitly, since they don't propagate to the group on Windows. After any worker exits, the supervisor puts stdin back out of raw mode in case the worker was killed before it could.
3665
3698
 
@@ -3673,13 +3706,13 @@ A worker that dies **after running healthily for at least 5s** — OOM, a native
3673
3706
  14:31:02 Development server was killed by SIGSEGV — restarting.
3674
3707
  ```
3675
3708
 
3676
- A worker that dies *sooner* than that failed to **boot** — a broken config, a port already taken — and it has already printed why. Restarting there would just reprint the same error and bury it, so the supervisor mirrors the exit code and stops.
3709
+ A worker that dies _sooner_ than that failed to **boot** — a broken config, a port already taken — and it has already printed why. Restarting there would just reprint the same error and bury it, so the supervisor mirrors the exit code and stops.
3677
3710
 
3678
- A busy port is now caught *before* the bind attempt: the HTTP connector calls `assertPortIsAvailable(port, host)` immediately before `listen()`, so the failure is `Port <port> is already in use on <host>. Stop the dev server (or whatever else is listening on port <port>) and run again, or start on a free port…` — not a raw `EADDRINUSE` surfacing from inside Fastify. Same connector, same preflight, for `warlock start`.
3711
+ A busy port is now caught _before_ the bind attempt: the HTTP connector calls `assertPortIsAvailable(port, host)` immediately before `listen()`, so the failure is `Port <port> is already in use on <host>. Stop the dev server (or whatever else is listening on port <port>) and run again, or start on a free port…` — not a raw `EADDRINUSE` surfacing from inside Fastify. Same connector, same preflight, for `warlock start`.
3679
3712
 
3680
3713
  Flapping is capped: more than 3 crashes inside 60s and the supervisor gives up rather than restarting behind your back.
3681
3714
 
3682
- An explicit restart (`r`, `u`, a config change) is a *request*, not a crash, so the uptime rule never swallows it.
3715
+ An explicit restart (`r`, `u`, a config change) is a _request_, not a crash, so the uptime rule never swallows it.
3683
3716
 
3684
3717
  ### Restart on config change
3685
3718
 
@@ -3695,7 +3728,7 @@ Set `devServer.restartOnConfigChange: false` for the previous behaviour (a warni
3695
3728
 
3696
3729
  On first boot `warlock dev` bundles its ESM loader hook and writes it to **your project's** `.warlock/loader-hook.mjs` — the hook runs in a fresh Node worker thread with no TypeScript loader of its own, so it has to be plain, pre-bundled ESM.
3697
3730
 
3698
- Because that file lives in your directory rather than core's, every npm import inside it is rewritten at generation time to an **absolute path resolved from core's own install**. A bare `import "esbuild"` there would resolve from *your* `node_modules`, and `esbuild` / `get-tsconfig` are core's dependencies, not yours.
3731
+ Because that file lives in your directory rather than core's, every npm import inside it is rewritten at generation time to an **absolute path resolved from core's own install**. A bare `import "esbuild"` there would resolve from _your_ `node_modules`, and `esbuild` / `get-tsconfig` are core's dependencies, not yours.
3699
3732
 
3700
3733
  :::note[Fixed in 4.9.2 — pnpm users]
3701
3734
  Before 4.9.2 those imports were left bare. npm and yarn hoist every transitive dependency into one flat tree, so they resolved by accident; pnpm's strict layout does not, and the dev server failed with `ERR_MODULE_NOT_FOUND: Cannot find package 'esbuild'`. The workaround was declaring `esbuild` and `get-tsconfig` in your own `package.json` — no longer needed, and you can drop them.
@@ -3757,12 +3790,13 @@ esbuild bundle of the app down to a single JS file in `dist/`. No flags — ever
3757
3790
  ```ts title="warlock.config.ts"
3758
3791
  export default defineConfig({
3759
3792
  build: {
3760
- outdir: "dist", // default — relative or absolute
3761
- outFile: "app.js", // default — bundle filename
3762
- minify: true, // default — esbuild minify
3763
- sourcemap: true, // default — true | false | "inline" | "linked"
3764
- singleBundle: false, // default — one runnable file, deps inlined
3765
- esmShim: true, // default — require/__filename/__dirname for bundled CJS
3793
+ outdir: "dist", // default — relative or absolute
3794
+ outFile: "app.js", // default — bundle filename
3795
+ minify: true, // default — esbuild minify
3796
+ sourcemap: true, // default — true | false | "inline" | "linked"
3797
+ singleBundle: false, // default — one runnable file, deps inlined
3798
+ esmShim: true, // default — require/__filename/__dirname for bundled CJS
3799
+ routeRegistrationTimeoutMs: 30_000, // default — route-registration child deadline
3766
3800
  },
3767
3801
  });
3768
3802
  ```
@@ -3774,8 +3808,9 @@ Defaults are sensible for the typical "Node service" deployment. Knobs to actual
3774
3808
  - **`minify: false`** — flip to debug a production-only bug. Larger bundle, readable stack traces.
3775
3809
  - **`sourcemap: "inline"`** — embed the source map in the bundle. Useful when your error reporter only captures the bundle and can't fetch a `.map` sidecar.
3776
3810
  - **`sourcemap: false`** — skip source maps entirely. Smaller artifact, but stack traces in production logs lose their file:line precision (and `warlock start` will not enable `--enable-source-maps` since there's nothing to map).
3777
- - **`singleBundle: true`** — inline dependencies so `node dist/app.js` runs with no `node_modules` and no launcher. Sets `packages: "bundle"` + `splitting: false` as **defaults you can override**. ⚠ Native `.node` addons are still emitted alongside — "single bundle" is one JS file *plus* any native addons. Do NOT reach for it as the default: keeping deps external is right when you deploy the folder.
3811
+ - **`singleBundle: true`** — inline dependencies so `node dist/app.js` runs with no `node_modules` and no launcher. Sets `packages: "bundle"` + `splitting: false` as **defaults you can override**. ⚠ Native `.node` addons are still emitted alongside — "single bundle" is one JS file _plus_ any native addons. Do NOT reach for it as the default: keeping deps external is right when you deploy the folder.
3778
3812
  - **`esmShim: false`** — only if you are certain nothing in the graph is CommonJS. Leave it on.
3813
+ - **`routeRegistrationTimeoutMs`** — increase on busy CI runners when static route registration needs more than 30 seconds. `WARLOCK_ROUTE_REGISTRATION_TIMEOUT_MS` overrides this value for a build; invalid values warn and use 30,000ms.
3779
3814
 
3780
3815
  ⚠ **The trap this replaced.** Setting `packages: "bundle"` by hand used to produce a clean build whose process died on start with `Error: Dynamic require of "node:assert" is not supported`. The output is ESM; bundled CJS deps call `require(...)` and read `__dirname`, and neither exists in an ES module, so the bundler substitutes a throwing stub. **A green `warlock build` was not evidence the bundle ran.** `esmShim` now injects `createRequire(import.meta.url)` and friends automatically for any ESM build, so both `singleBundle` and a hand-written `packages: "bundle"` work. An existing hand-written `banner` is preserved — the shim is prepended, not substituted.
3781
3816
 
@@ -3833,6 +3868,41 @@ npx warlock start --inspect # → spawns node --enable-source-maps
3833
3868
  npx warlock start --max-old-space-size=4096 # → spawns node --enable-source-maps --max-old-space-size=4096 dist/app.js
3834
3869
  ```
3835
3870
 
3871
+ ### Deploy one build in parts
3872
+
3873
+ One production build can run as API, page, and background-worker processes.
3874
+ Omit `--role` to run every role (the existing default); combine roles with a
3875
+ comma-separated list:
3876
+
3877
+ ```bash
3878
+ npx warlock start --role=api
3879
+ npx warlock start --role=web --sites=admin,storefront
3880
+ npx warlock start --role=worker
3881
+ npx warlock start --role=web,api
3882
+ ```
3883
+
3884
+ `--sites=a,b` is valid only with the `web` role and installs the listed site
3885
+ keys' pages (shared non-site pages remain installed). Unknown roles, an empty
3886
+ list, `--sites` without `web`, and unknown site keys fail boot rather than
3887
+ silently serving the wrong traffic.
3888
+
3889
+ An API or web role binds HTTP. A worker-only process binds no HTTP port; it
3890
+ loads worker work, starts queue workers and the scheduler there, and its
3891
+ readiness banner reports `worker ready`. `warlock dev` always runs every role.
3892
+
3893
+ Inside application code, inspect the roles resolved at bootstrap:
3894
+
3895
+ ```ts
3896
+ import { Application } from "@warlock.js/core";
3897
+
3898
+ if (Application.hasRole("worker")) {
3899
+ // worker-only integration
3900
+ }
3901
+
3902
+ Application.roles; // ReadonlySet<"api" | "web" | "worker">
3903
+ Application.sites; // ReadonlySet<string> | undefined (undefined = all sites)
3904
+ ```
3905
+
3836
3906
  Everything you pass after `start` is forwarded to the spawned Node process. Use this to attach a debugger (`--inspect`), tune memory (`--max-old-space-size`), or pass any other Node flag without editing the command.
3837
3907
 
3838
3908
  ### Source maps
@@ -3875,9 +3945,9 @@ Nothing else is needed for pnpm. Warlock never requires an app to declare a pack
3875
3945
 
3876
3946
  **A success line on stdout means the app is serving requests.** That is a contract, not a convention, and you can build a CI gate or a health probe on it.
3877
3947
 
3878
- | Stream | Carries |
3879
- | ---------- | -------------------------------------------------------------- |
3880
- | **stdout** | the started banner, and start failures. Nothing else. |
3948
+ | Stream | Carries |
3949
+ | ---------- | -------------------------------------------------------------------------------------- |
3950
+ | **stdout** | the started banner, and start failures. Nothing else. |
3881
3951
  | **stderr** | progress (`🚀 Starting production server...`), diagnostics, the application's own logs |
3882
3952
 
3883
3953
  The started banner prints **only** when the running application reports a completed boot — not when the command starts, not when the child is spawned. A child that dies before reporting is a failed start: the failure is written to **both** streams (stderr for humans and log collectors, stdout so a supervisor greping for the banner finds a failure rather than silence), and `warlock start` exits non-zero **even when the child itself exited `0`**.
@@ -3913,7 +3983,7 @@ else is listening on port 3000) and run again, or start on a free port — e.g.
3913
3983
  startHttpTestServer({ port: 3001 }).
3914
3984
  ```
3915
3985
 
3916
- **The process exits `78` (`EX_CONFIG`), and `warlock dev`'s supervisor treats that as terminal — it prints once and stops rather than restarting.** That code is the counterpart of `75`, which a worker uses to ask for a fresh process. The distinction is the point: a code error is worth restarting, and a *startup precondition* — a port held by something else, a port value that is not a port, a boot validator that rejected — cannot change because we tried again six seconds later. Retrying one only reprints the diagnostic and then scrolls it away.
3986
+ **The process exits `78` (`EX_CONFIG`), and `warlock dev`'s supervisor treats that as terminal — it prints once and stops rather than restarting.** That code is the counterpart of `75`, which a worker uses to ask for a fresh process. The distinction is the point: a code error is worth restarting, and a _startup precondition_ — a port held by something else, a port value that is not a port, a boot validator that rejected — cannot change because we tried again six seconds later. Retrying one only reprints the diagnostic and then scrolls it away.
3917
3987
 
3918
3988
  `EACCES` on the port is treated the same way as `EADDRINUSE` (a privileged port you may not bind is also "not available"); anything else the probe throws is rethrown untouched.
3919
3989
 
@@ -3928,7 +3998,7 @@ Configured http.port "03999" normalised to 3999.
3928
3998
  `warlock start` spawns the bundle with an IPC channel and sets `WARLOCK_BOOT_SIGNAL=1` on it. `Application.markBooted()` — which the production entry calls after the late-phase connectors (http, socket) are up — sends one versioned message and closes the channel:
3929
3999
 
3930
4000
  ```ts
3931
- { type: "warlock:ready", version: 1, pid, at, environment, runtimeStrategy, bootDurationMs?, port? }
4001
+ { type: "warlock:ready", version: 1, pid, at, environment, runtimeStrategy, bootDurationMs?, port?, roles }
3932
4002
  ```
3933
4003
 
3934
4004
  Three consequences worth knowing:
@@ -3943,11 +4013,11 @@ If you need the same fact inside the app, use `Application.onceBooted()` / `Appl
3943
4013
 
3944
4014
  `Application.environment` and `Application.runtimeStrategy` are separate axes:
3945
4015
 
3946
- | Mode | `environment` | `runtimeStrategy` | How |
3947
- | ---------------- | --------------- | ----------------- | ----------------------------------------- |
3948
- | `warlock dev` | `development`* | `development` | preload force-sets `runtimeStrategy` |
3949
- | `warlock build` | n/a (no app boots) | n/a | only loads warlock.config.ts |
3950
- | `warlock start` | `production`* | `production`* | usually set via `NODE_ENV` in the env |
4016
+ | Mode | `environment` | `runtimeStrategy` | How |
4017
+ | --------------- | ------------------ | ----------------- | ------------------------------------- |
4018
+ | `warlock dev` | `development`* | `development` | preload force-sets `runtimeStrategy` |
4019
+ | `warlock build` | n/a (no app boots) | n/a | only loads warlock.config.ts |
4020
+ | `warlock start` | `production`* | `production`* | usually set via `NODE_ENV` in the env |
3951
4021
 
3952
4022
  `*` — `environment` follows `NODE_ENV`. The dev command doesn't force it, but the default in most projects is `development`. `start` doesn't force it either; deployments set `NODE_ENV=production` themselves.
3953
4023
 
@@ -4006,7 +4076,7 @@ export default defineConfig({
4006
4076
 
4007
4077
  CI sets `BUILD_OUT=build/<sha>` per pipeline. `warlock start` reads the same config and finds the bundle without any hardcoded paths.
4008
4078
 
4009
- **This recipe only started working in 4.11.0.** Before that, `warlock.config.ts` was evaluated *before* any `.env` file was read, so every `env()` call in it returned its default — silently, under every command. If you copied this recipe earlier and concluded that `BUILD_OUT` was ignored, it was.
4079
+ **This recipe only started working in 4.11.0.** Before that, `warlock.config.ts` was evaluated _before_ any `.env` file was read, so every `env()` call in it returned its default — silently, under every command. If you copied this recipe earlier and concluded that `BUILD_OUT` was ignored, it was.
4010
4080
 
4011
4081
  **Which file the value comes from is decided by `NODE_ENV`, and no Warlock command sets it.** `env()` reads `.env.<NODE_ENV>` when that file exists and falls back to plain `.env`. So:
4012
4082
 
@@ -4086,10 +4156,7 @@ Two APIs over the same engine. The fluent `Mail` builder reads top-to-bottom in
4086
4156
  ```ts
4087
4157
  import { Mail } from "@warlock.js/core";
4088
4158
 
4089
- await Mail.to("user@example.com")
4090
- .subject("Welcome!")
4091
- .text("Thanks for joining.")
4092
- .send();
4159
+ await Mail.to("user@example.com").subject("Welcome!").text("Thanks for joining.").send();
4093
4160
  ```
4094
4161
 
4095
4162
  Or:
@@ -4133,10 +4200,25 @@ For named mailers (multi-provider apps), export a `MailersConfig` instead:
4133
4200
 
4134
4201
  ```ts
4135
4202
  const config: MailersConfig = {
4136
- default: { host: "smtp.sendgrid.net", port: 587, username: "apikey", password: env("SENDGRID_KEY") },
4203
+ default: {
4204
+ host: "smtp.sendgrid.net",
4205
+ port: 587,
4206
+ username: "apikey",
4207
+ password: env("SENDGRID_KEY"),
4208
+ },
4137
4209
  mailers: {
4138
- marketing: { host: "smtp.mailchimp.com", port: 587, username: env("MC_USER"), password: env("MC_PASS") },
4139
- transactional: { host: "smtp.postmark.com", port: 587, username: env("PM_USER"), password: env("PM_PASS") },
4210
+ marketing: {
4211
+ host: "smtp.mailchimp.com",
4212
+ port: 587,
4213
+ username: env("MC_USER"),
4214
+ password: env("MC_PASS"),
4215
+ },
4216
+ transactional: {
4217
+ host: "smtp.postmark.com",
4218
+ port: 587,
4219
+ username: env("PM_USER"),
4220
+ password: env("PM_PASS"),
4221
+ },
4140
4222
  },
4141
4223
  };
4142
4224
  ```
@@ -4168,9 +4250,9 @@ The mail pipeline switches behavior on a global mode:
4168
4250
  ```ts
4169
4251
  import { setMailMode } from "@warlock.js/core";
4170
4252
 
4171
- setMailMode("production"); // actually send (default)
4172
- setMailMode("development"); // log subject + recipient, no send
4173
- setMailMode("test"); // capture to test mailbox, no send
4253
+ setMailMode("production"); // actually send (default)
4254
+ setMailMode("development"); // log subject + recipient, no send
4255
+ setMailMode("test"); // capture to test mailbox, no send
4174
4256
  ```
4175
4257
 
4176
4258
  Production sends. Development logs and returns a fake-success `MailResult`. Test captures every call into an in-memory mailbox (cleared between tests) and returns success — handy for asserting "the welcome email got sent" without hitting SMTP.
@@ -4183,7 +4265,7 @@ Production sends. Development logs and returns a fake-success `MailResult`. Test
4183
4265
 
4184
4266
  ```ts
4185
4267
  await Mail.to("a@example.com")
4186
- .to(["a@example.com", "b@example.com"]) // override
4268
+ .to(["a@example.com", "b@example.com"]) // override
4187
4269
  .cc("manager@example.com")
4188
4270
  .bcc("audit@example.com")
4189
4271
  .replyTo("support@example.com")
@@ -4224,15 +4306,15 @@ await Mail.to("u@e.com")
4224
4306
 
4225
4307
  ### Other knobs
4226
4308
 
4227
- | Method | Purpose |
4228
- | ------------------------------------- | --------------------------------------------- |
4229
- | `.priority("high" \| "normal" \| "low")` | priority header |
4230
- | `.headers({ "X-Foo": "bar" })` | replace custom headers |
4231
- | `.header("X-Foo", "bar")` | add one header |
4232
- | `.tags(["welcome"])` / `.tag("transactional")` | categorization tags |
4233
- | `.correlationId("req-123")` | tracking id (logged with sends) |
4234
- | `.config(MailConfigurations)` | one-off override of the global config |
4235
- | `.mailer("marketing")` | route via a named mailer from config |
4309
+ | Method | Purpose |
4310
+ | ---------------------------------------------- | ------------------------------------- |
4311
+ | `.priority("high" \| "normal" \| "low")` | priority header |
4312
+ | `.headers({ "X-Foo": "bar" })` | replace custom headers |
4313
+ | `.header("X-Foo", "bar")` | add one header |
4314
+ | `.tags(["welcome"])` / `.tag("transactional")` | categorization tags |
4315
+ | `.correlationId("req-123")` | tracking id (logged with sends) |
4316
+ | `.config(MailConfigurations)` | one-off override of the global config |
4317
+ | `.mailer("marketing")` | route via a named mailer from config |
4236
4318
 
4237
4319
  ### Per-mail event handlers
4238
4320
 
@@ -4245,9 +4327,9 @@ await Mail.to("u@e.com")
4245
4327
  .beforeSending((mail) => {
4246
4328
  // mutate `mail` or return false to cancel
4247
4329
  })
4248
- .onSent((mail, result, error) => {}) // always fires after attempt
4249
- .onSuccess((mail, result) => {}) // only on success
4250
- .onError((mail, error) => {}) // only on failure
4330
+ .onSent((mail, result, error) => {}) // always fires after attempt
4331
+ .onSuccess((mail, result) => {}) // only on success
4332
+ .onError((mail, error) => {}) // only on failure
4251
4333
  .send();
4252
4334
  ```
4253
4335
 
@@ -4317,6 +4399,23 @@ mailEvents.onBeforeSending((mail) => {
4317
4399
 
4318
4400
  Global hooks fire for **every** mail. Per-mail handlers from the builder / `sendMail` fire only for that one send.
4319
4401
 
4402
+ ### Observe captured development and test mail
4403
+
4404
+ Development and test sends create a `CapturedMail` and synchronously emit the
4405
+ global `mail.captured` event. Subscribe with `mailEvents.onCaptured`:
4406
+
4407
+ ```ts
4408
+ import { mailEvents, type CapturedMail } from "@warlock.js/core";
4409
+
4410
+ mailEvents.onCaptured((mail: CapturedMail) => {
4411
+ console.log(mail.id, mail.options.subject);
4412
+ });
4413
+ ```
4414
+
4415
+ `CapturedMail.id` is the mail's generated or supplied id. This event is for
4416
+ development/test tooling; production sends do not emit it. Listener failures
4417
+ are logged and never interrupt mail delivery.
4418
+
4320
4419
  For correlated tracking of a specific mail, generate an id and subscribe by id:
4321
4420
 
4322
4421
  ```ts
@@ -4370,10 +4469,11 @@ Captured mail shape:
4370
4469
 
4371
4470
  ```ts
4372
4471
  type CapturedMail = {
4373
- options: MailOptions; // original payload
4472
+ id: string;
4473
+ options: MailOptions; // original payload
4374
4474
  normalized: NormalizedMail; // post-normalization (arrays, resolved from)
4375
4475
  timestamp: Date;
4376
- result?: MailResult; // in test mode, always success
4476
+ result?: MailResult; // in test mode, always success
4377
4477
  error?: MailError;
4378
4478
  };
4379
4479
  ```
@@ -5177,7 +5277,7 @@ Some tests need the full stack: route matching, middleware chain, validation, co
5177
5277
 
5178
5278
  `startHttpTestServer()` is the bootstrap. `testGet` / `testPost` / `expectJson` are the call helpers. Both ship in `@warlock.js/core/tests`.
5179
5279
 
5180
- ⚠ **Changed in 4.13.0 — the import is a subpath now.** These helpers used to be re-exported from the package root; they are not any more, because that put the test helpers into every application's production module graph. `import { testGet } from "@warlock.js/core"` now fails with *"has no exported member"* — **add `/tests` to the specifier and nothing else changes.**
5280
+ ⚠ **Changed in 4.13.0 — the import is a subpath now.** These helpers used to be re-exported from the package root; they are not any more, because that put the test helpers into every application's production module graph. `import { testGet } from "@warlock.js/core"` now fails with _"has no exported member"_ — **add `/tests` to the specifier and nothing else changes.**
5181
5281
 
5182
5282
  ## The shape
5183
5283
 
@@ -5218,7 +5318,7 @@ import { startHttpTestServer, stopHttpTestServer } from "@warlock.js/core/tests"
5218
5318
  - Loads `warlock.config.ts` + bootstrap + `filesOrchestrator` (without file watching).
5219
5319
  - Loads `src/config/*.ts`.
5220
5320
  - Loads every module (`routes.ts`, `main.ts`, `events/*.ts`).
5221
- - Starts **ALL** connectors, including HTTP.
5321
+ - Starts **ALL** connectors, including HTTP, by default.
5222
5322
 
5223
5323
  Unlike the dev server, it doesn't watch files, doesn't do HMR, doesn't run health checkers. Just a working HTTP endpoint listening on the configured port.
5224
5324
 
@@ -5238,6 +5338,14 @@ The explicit port wins over `http.port` — including over `HTTP_PORT` in `.env`
5238
5338
 
5239
5339
  The request helpers follow with no extra wiring: the bound port is published as `WARLOCK_TEST_SERVER_PORT`, which the workers inherit, and `getTestServerUrl()` prefers it over their own `.env`-resolved `http.port`. `stopHttpTestServer()` withdraws it.
5240
5340
 
5341
+ ### API-only suites — `startHttpTestServer({ web: false })`
5342
+
5343
+ ```ts
5344
+ await startHttpTestServer({ web: false });
5345
+ ```
5346
+
5347
+ Use this in `globalSetup` for a suite that exercises API routes only. It still boots the HTTP connector and every other configured connector, but excludes the optional `web` connector before its lifecycle begins. That means web's lazy delegate never constructs `WebConnector`, and Vite is never created. Omitting `web` keeps the default full-stack behavior.
5348
+
5241
5349
  ### The port is preflighted
5242
5350
 
5243
5351
  Before binding, the test server checks the resolved port is free and fails with an instruction if it isn't:
@@ -5293,8 +5401,8 @@ import { defineConfig } from "vitest/config";
5293
5401
  export default defineConfig({
5294
5402
  plugins: [lowerStage3Decorators(), mongezVite()],
5295
5403
  test: {
5296
- globalSetup: "./src/test-global-setup.ts", // ← starts the HTTP server
5297
- setupFiles: ["./src/test-setup.ts"], // ← setupTest + afterAll(teardownTest), per test file
5404
+ globalSetup: "./src/test-global-setup.ts", // ← starts the HTTP server
5405
+ setupFiles: ["./src/test-setup.ts"], // ← setupTest + afterAll(teardownTest), per test file
5298
5406
  environment: "node",
5299
5407
  globals: false,
5300
5408
  include: ["src/app/**/*.test.ts"],
@@ -5310,6 +5418,17 @@ Both files (and this config, with `lowerStage3Decorators()` first so decorated m
5310
5418
 
5311
5419
  **HTTP is the exception and stays in `globalSetup`**, which genuinely does run once in the main vitest process and owns a real port. **That split is the point:** one server for the whole run, one framework per test file.
5312
5420
 
5421
+ ### Keep Vitest setup out of development
5422
+
5423
+ `warlock dev` (and `warlock build`) load your app's `vite.config.ts`. Anything you put there for tests runs in development too. Guard vitest-only setup:
5424
+
5425
+ ```ts
5426
+ // vite.config.ts
5427
+ if (process.env.VITEST) {
5428
+ Object.assign(process.env, testEnv); // test database, test keys...
5429
+ }
5430
+ ```
5431
+
5313
5432
  ## HTTP request helpers
5314
5433
 
5315
5434
  Everything is built on native `fetch` — no extra dependency, no special wire format.
@@ -5319,7 +5438,7 @@ Everything is built on native `fetch` — no extra dependency, no special wire f
5319
5438
  ```ts
5320
5439
  import { getTestServerUrl } from "@warlock.js/core/tests";
5321
5440
 
5322
- const url = getTestServerUrl(); // → "http://localhost:2031" (defaults)
5441
+ const url = getTestServerUrl(); // → "http://localhost:2031" (defaults)
5323
5442
  ```
5324
5443
 
5325
5444
  Reads `http.host` (default `"localhost"`) and `http.port` (default `2031`) from config — unless `startHttpTestServer` published a port, which wins, since a worker's own config never sees the `{ port }` option passed in `globalSetup`. If you change the HTTP config, helpers follow automatically.
@@ -5357,9 +5476,9 @@ import { expectJson, parseJsonResponse } from "@warlock.js/core/tests";
5357
5476
  const body = await parseJsonResponse<MyShape>(response);
5358
5477
 
5359
5478
  // Assert status + parse in one call
5360
- const body = await expectJson<MyShape>(response); // expects 200
5361
- const body = await expectJson<MyShape>(response, 201); // expects 201
5362
- const body = await expectJson<MyShape>(response, 404); // expects 404 (testing error paths)
5479
+ const body = await expectJson<MyShape>(response); // expects 200
5480
+ const body = await expectJson<MyShape>(response, 201); // expects 201
5481
+ const body = await expectJson<MyShape>(response, 404); // expects 404 (testing error paths)
5363
5482
  ```
5364
5483
 
5365
5484
  `expectJson` throws with the actual status + response body when the assertion fails — no chasing "got 500" messages without context.
@@ -5395,7 +5514,7 @@ describe("Products API — happy path", () => {
5395
5514
 
5396
5515
  ```ts
5397
5516
  it("rejects missing required fields with 400", async () => {
5398
- const response = await testPost("/products", { name: "" }); // price missing
5517
+ const response = await testPost("/products", { name: "" }); // price missing
5399
5518
  const body = await expectJson<{ errors: Array<{ key: string }> }>(response, 400);
5400
5519
 
5401
5520
  expect(body.errors.some((e) => e.key === "price")).toBe(true);
@@ -5520,13 +5639,13 @@ For unit tests, you import the thing under test and call it directly. No HTTP, n
5520
5639
 
5521
5640
  ⚠ **Corrected in 4.14.0 — `setupTest` is CALLED once per TEST FILE, not once per worker.** Every version of this skill through 4.13.0 said "per worker", the generated `src/test-setup.ts` carries a `Per-Worker Test Setup` comment saying the same thing, and **both were wrong.** Vitest runs `setupFiles` before **each test file**, and the setup module's registry is rebuilt every time — measured across all four `pool` × `isolate` combinations.
5522
5641
 
5523
- ⛔ **If your project was generated before 4.14.0, fix BOTH the comment and the call.** The comment is false, **and** the generated `setupTest({ connectors: true })` is now an *explicit* value that overrides your `src/config/tests.ts`. **Bare `setupTest()` is the correct call.**
5642
+ ⛔ **If your project was generated before 4.14.0, fix BOTH the comment and the call.** The comment is false, **and** the generated `setupTest({ connectors: true })` is now an _explicit_ value that overrides your `src/config/tests.ts`. **Bare `setupTest()` is the correct call.**
5524
5643
 
5525
5644
  **The lifetime is FILE-SCOPED, on purpose.** Your setup file bootstraps the framework and its `afterAll(teardownTest)` closes it, once per test file. **One owner, one pairing — correct under every pool, every isolation setting, and watch mode.**
5526
5645
 
5527
- ⚠ **A worker-scoped lifetime is possible and is deliberately not shipped yet.** Lifecycle state now lives in the worker runtime, so leaving the framework running would let every file in a worker share one bootstrap. Two things block claiming it: under `pool: "threads"` we cannot observe whether Node reclaims a torn-down thread's sockets and pools, and **in watch mode Vitest reuses workers between reruns, so there is no recycle and no cleanup owner.** It gets taken when the real cost is measured and the integration is chosen, not inherited. See *Lifecycle and repeated calls* below.
5646
+ ⚠ **A worker-scoped lifetime is possible and is deliberately not shipped yet.** Lifecycle state now lives in the worker runtime, so leaving the framework running would let every file in a worker share one bootstrap. Two things block claiming it: under `pool: "threads"` we cannot observe whether Node reclaims a torn-down thread's sockets and pools, and **in watch mode Vitest reuses workers between reruns, so there is no recycle and no cleanup owner.** It gets taken when the real cost is measured and the integration is chosen, not inherited. See _Lifecycle and repeated calls_ below.
5528
5647
 
5529
- ⚠ **Changed in 4.13.0 — the import is a subpath now.** `setupTest` used to be re-exported from the package root; it is not any more, because that put the test helpers into every application's production module graph. `import { setupTest } from "@warlock.js/core"` now fails with *"has no exported member"* — **add `/tests` to the specifier and nothing else changes.**
5648
+ ⚠ **Changed in 4.13.0 — the import is a subpath now.** `setupTest` used to be re-exported from the package root; it is not any more, because that put the test helpers into every application's production module graph. `import { setupTest } from "@warlock.js/core"` now fails with _"has no exported member"_ — **add `/tests` to the specifier and nothing else changes.**
5530
5649
 
5531
5650
  ## The shape
5532
5651
 
@@ -5543,7 +5662,7 @@ describe("registerUserService", () => {
5543
5662
  });
5544
5663
 
5545
5664
  expect(user.get("email")).toBe("test@example.com");
5546
- expect(user.get("password")).not.toBe("secret"); // hashed by useHashedPassword()
5665
+ expect(user.get("password")).not.toBe("secret"); // hashed by useHashedPassword()
5547
5666
 
5548
5667
  const found = await usersRepository.first({ email: "test@example.com" });
5549
5668
  expect(found).toBeDefined();
@@ -5568,18 +5687,18 @@ What it does (in order):
5568
5687
  3. Runs `bootstrap()` — env, app, prestart hooks.
5569
5688
  4. Initializes the `filesOrchestrator` (module/route/config discovery, no file watching).
5570
5689
  5. Loads all `src/config/*.ts` files.
5571
- 6. Resolves the connector selection — **an explicit parameter wins, then `tests.connectors` from config, then the `true` default.** See *Selecting connectors* below.
5690
+ 6. Resolves the connector selection — **an explicit parameter wins, then `tests.connectors` from config, then the `true` default.** See _Selecting connectors_ below.
5572
5691
  7. Starts the chosen connectors — but **never `http`** when you pass a boolean. HTTP is the global-setup's job.
5573
5692
 
5574
5693
  The result: DB/cache/logger/storage connections your code can use. Models save, repositories query, services run. Same code as production, just isolated to the test process.
5575
5694
 
5576
5695
  ### The `connectors` parameter
5577
5696
 
5578
- | Value | Boots | Use when |
5579
- | ---------------------- | ---------------------------------------------------------------- | ----------------------------------------------------- |
5580
- | `true` *(default)* | Every connector except `http` — via `startWithout(["http"])` (db, cache, logger, storage, and socket if configured) | Most service / repository / model tests. |
5581
- | `false` | None | Pure logic tests with no DB / cache touches (parsers, validators, util functions). |
5582
- | `["database", "cache"]` | Just those, in that order | A test that only needs DB but not, say, the storage driver. |
5697
+ | Value | Boots | Use when |
5698
+ | ----------------------- | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
5699
+ | `true` _(default)_ | Every connector except `http` — via `startWithout(["http"])` (db, cache, logger, storage, and socket if configured) | Most service / repository / model tests. |
5700
+ | `false` | None | Pure logic tests with no DB / cache touches (parsers, validators, util functions). |
5701
+ | `["database", "cache"]` | Just those, in that order | A test that only needs DB but not, say, the storage driver. |
5583
5702
 
5584
5703
  The default `true` is the sane choice. Reach for `false` when the unit you're testing genuinely doesn't talk to any framework subsystem — pulling up a DB connection **for every file** just to test a string parser is wasted setup time.
5585
5704
 
@@ -5595,25 +5714,25 @@ export default testsConfigurations;
5595
5714
 
5596
5715
  ⚠ **BREAKING in 4.14.0 — the precedence flipped.**
5597
5716
 
5598
- | | Order |
5599
- |---|---|
5600
- | **4.13.0 and earlier** | `tests.connectors` config **>** `setupTest({ connectors })` parameter **>** `true` |
5601
- | **4.14.0 onward** | **explicit `setupTest({ connectors })` parameter** **>** `tests.connectors` config **>** `true` |
5717
+ | | Order |
5718
+ | ---------------------- | ----------------------------------------------------------------------------------------------- |
5719
+ | **4.13.0 and earlier** | `tests.connectors` config **>** `setupTest({ connectors })` parameter **>** `true` |
5720
+ | **4.14.0 onward** | **explicit `setupTest({ connectors })` parameter** **>** `tests.connectors` config **>** `true` |
5602
5721
 
5603
- **An explicit call-site value now beats project config.** If your project sets `tests.connectors` *and* some test file passes `connectors` explicitly, **that file will start a different connector set after upgrading.** Search for `setupTest({` across your tests before you upgrade — a call passing `connectors` was previously ignored and is now honoured.
5722
+ **An explicit call-site value now beats project config.** If your project sets `tests.connectors` _and_ some test file passes `connectors` explicitly, **that file will start a different connector set after upgrading.** Search for `setupTest({` across your tests before you upgrade — a call passing `connectors` was previously ignored and is now honoured.
5604
5723
 
5605
5724
  **"Explicit" means you supplied a non-`undefined` value.** Both of these fall through to config:
5606
5725
 
5607
5726
  ```ts
5608
- await setupTest(); // → tests.connectors, else true
5609
- await setupTest({}); // → tests.connectors, else true
5727
+ await setupTest(); // → tests.connectors, else true
5728
+ await setupTest({}); // → tests.connectors, else true
5610
5729
  await setupTest({ connectors: undefined }); // → tests.connectors, else true — NOT "start none"
5611
5730
  ```
5612
5731
 
5613
5732
  The `undefined` rule is deliberate: an optional variable that happens to be `undefined` must not silently erase your project config.
5614
5733
 
5615
5734
  ```ts
5616
- await setupTest({ connectors: false }); // → none, even if config says otherwise
5735
+ await setupTest({ connectors: false }); // → none, even if config says otherwise
5617
5736
  await setupTest({ connectors: ["database"] }); // → exactly that, even if config differs
5618
5737
  ```
5619
5738
 
@@ -5640,7 +5759,7 @@ afterAll(teardownTest);
5640
5759
  ⛔ **Three things changed here in 4.14.0. If you generated this file earlier, replace all three — it is not a comment fix.**
5641
5760
 
5642
5761
  1. **`afterAll(teardownTest)` is new and mandatory.** Nothing else closes the framework your tests started. This is what makes the lifetime file-scoped and owned rather than left running.
5643
- 2. **The call is now bare `setupTest()`, not `setupTest({ connectors: true })`.** Under the flipped precedence, passing `true` is an *explicit* value and would override `tests.connectors` for **every file in the project.** Bare means "whatever this project configured, else the default".
5762
+ 2. **The call is now bare `setupTest()`, not `setupTest({ connectors: true })`.** Under the flipped precedence, passing `true` is an _explicit_ value and would override `tests.connectors` for **every file in the project.** Bare means "whatever this project configured, else the default".
5644
5763
  3. **The comment used to say `Per-Worker Test Setup` / "Runs in EACH Vitest worker thread".** False — see the top of this skill.
5645
5764
 
5646
5765
  ```ts title="vite.config.ts"
@@ -5651,8 +5770,8 @@ import { defineConfig } from "vitest/config";
5651
5770
  export default defineConfig({
5652
5771
  plugins: [lowerStage3Decorators(), mongezVite()],
5653
5772
  test: {
5654
- globalSetup: "./src/test-global-setup.ts", // ← HTTP server (see test-http skill)
5655
- setupFiles: ["./src/test-setup.ts"], // ← runs setupTest before EACH test file
5773
+ globalSetup: "./src/test-global-setup.ts", // ← HTTP server (see test-http skill)
5774
+ setupFiles: ["./src/test-setup.ts"], // ← runs setupTest before EACH test file
5656
5775
  environment: "node",
5657
5776
  globals: false,
5658
5777
  include: ["src/app/**/*.test.ts"],
@@ -5662,6 +5781,17 @@ export default defineConfig({
5662
5781
 
5663
5782
  The `mongezVite()` plugin handles TypeScript path resolution and the framework's module shape. Without it, your imports break the moment vitest tries to load a Warlock module. `lowerStage3Decorators()` goes **first** — it lets decorated Cascade models (`@RegisterModel`, …) load under Vitest 4 / Vite 8; see [`@warlock.js/core/lower-stage3-decorators/SKILL.md`](@warlock.js/core/lower-stage3-decorators/SKILL.md).
5664
5783
 
5784
+ ### Keep Vitest setup out of development
5785
+
5786
+ `warlock dev` (and `warlock build`) load your app's `vite.config.ts`. Anything you put there for tests runs in development too. Guard vitest-only setup:
5787
+
5788
+ ```ts
5789
+ // vite.config.ts
5790
+ if (process.env.VITEST) {
5791
+ Object.assign(process.env, testEnv); // test database, test keys...
5792
+ }
5793
+ ```
5794
+
5665
5795
  Tests live colocated with the module: `src/app/<module>/tests/*.test.ts`. The `include` pattern picks them up.
5666
5796
 
5667
5797
  ## Patterns
@@ -5687,9 +5817,9 @@ describe("createProductService", () => {
5687
5817
  it("rejects duplicate names", async () => {
5688
5818
  await Product.create({ name: "Existing", price: 10 });
5689
5819
 
5690
- await expect(
5691
- createProductService({ name: "Existing", price: 20 }),
5692
- ).rejects.toThrow(/already exists/i);
5820
+ await expect(createProductService({ name: "Existing", price: 20 })).rejects.toThrow(
5821
+ /already exists/i,
5822
+ );
5693
5823
  });
5694
5824
  });
5695
5825
  ```
@@ -5759,7 +5889,7 @@ afterEach(async () => {
5759
5889
 
5760
5890
  Vitest runs the tests within one file sequentially, so an `afterEach` truncate gives each test a clean slate.
5761
5891
 
5762
- ⚠ **Cross-*file* isolation is not solved by this.** Separate workers get separate **connections**, not separate **rows** — two files pointed at the same database see each other's committed data regardless of pool or worker count. **Truncate what your file wrote; don't assume the worker boundary did it for you.** Real data isolation (DB-per-worker, transaction-per-test) is a separate piece of work and is not in this release.
5892
+ ⚠ **Cross-_file_ isolation is not solved by this.** Separate workers get separate **connections**, not separate **rows** — two files pointed at the same database see each other's committed data regardless of pool or worker count. **Truncate what your file wrote; don't assume the worker boundary did it for you.** Real data isolation (DB-per-worker, transaction-per-test) is a separate piece of work and is not in this release.
5763
5893
 
5764
5894
  ### Skipping connectors for pure logic tests
5765
5895
 
@@ -5769,7 +5899,7 @@ import { setupTest } from "@warlock.js/core/tests";
5769
5899
  import { slugify } from "../utils/slugify";
5770
5900
 
5771
5901
  beforeAll(async () => {
5772
- await setupTest({ connectors: false }); // starts no connectors at all
5902
+ await setupTest({ connectors: false }); // starts no connectors at all
5773
5903
  });
5774
5904
 
5775
5905
  describe("slugify", () => {
@@ -5796,7 +5926,7 @@ beforeAll(async () => {
5796
5926
  });
5797
5927
 
5798
5928
  afterAll(async () => {
5799
- await teardownTest(); // ← REQUIRED, see below
5929
+ await teardownTest(); // ← REQUIRED, see below
5800
5930
  });
5801
5931
  ```
5802
5932
 
@@ -5817,16 +5947,16 @@ afterAll(async () => {
5817
5947
 
5818
5948
  `setupTest` / `teardownTest` are a pair. **The harness that calls one owns calling the other in the same context.**
5819
5949
 
5820
- | Call | Behaviour |
5821
- |---|---|
5822
- | `setupTest(x)` while idle | bootstraps |
5823
- | `setupTest(x)` while already ready with the **same** effective options | no-op |
5824
- | `setupTest(y)` while ready or starting with **different** effective options | ⛔ **rejects**, naming active vs requested |
5825
- | two concurrent `setupTest(x)` calls | share one startup |
5826
- | `setupTest` after a failed setup | allowed — a failed setup unwinds and returns to idle |
5827
- | `teardownTest()` while idle | no-op |
5828
- | two concurrent `teardownTest()` calls | share one shutdown |
5829
- | `setupTest(y)` after a **successful** teardown | allowed, different options fine |
5950
+ | Call | Behaviour |
5951
+ | --------------------------------------------------------------------------- | ---------------------------------------------------- |
5952
+ | `setupTest(x)` while idle | bootstraps |
5953
+ | `setupTest(x)` while already ready with the **same** effective options | no-op |
5954
+ | `setupTest(y)` while ready or starting with **different** effective options | ⛔ **rejects**, naming active vs requested |
5955
+ | two concurrent `setupTest(x)` calls | share one startup |
5956
+ | `setupTest` after a failed setup | allowed — a failed setup unwinds and returns to idle |
5957
+ | `teardownTest()` while idle | no-op |
5958
+ | two concurrent `teardownTest()` calls | share one shutdown |
5959
+ | `setupTest(y)` after a **successful** teardown | allowed, different options fine |
5830
5960
 
5831
5961
  **"Same options" is compared by meaning, not by literal value** — connector arrays are deduplicated and compared as sets, so `["cache", "database"]` and `["database", "cache", "cache"]` are the same selection.
5832
5962
 
@@ -5841,7 +5971,7 @@ afterAll(async () => {
5841
5971
  ```ts title="src/config/tests.ts"
5842
5972
  const testsConfigurations = {
5843
5973
  connectors: ["database", "logger"],
5844
- setupTimeout: 120000, // milliseconds — this is the default
5974
+ setupTimeout: 120000, // milliseconds — this is the default
5845
5975
  };
5846
5976
 
5847
5977
  export default testsConfigurations;
@@ -5860,9 +5990,9 @@ in `src/config/tests.ts` — milliseconds, default 120000.
5860
5990
  1. **It bounds the setup ATTEMPT, not teardown separately.** `teardownTest()` awaits the same attempt, so it inherits the bound — **one timer, not two.** A second teardown-side deadline was tried and rejected: it re-introduced the unbounded re-entry this whole guard exists to remove.
5861
5991
  2. **Expiry poisons the lifecycle**, it does not return to `idle`. The attempt may have started connectors nobody can now account for, so pretending the runtime is clean would be worse than refusing.
5862
5992
  3. **⛔ An invalid `setupTimeout` throws, naming the bad value.** Zero, negative and non-numeric all fail loudly rather than falling back to the default — a silent fallback would hide a typo behind a working suite.
5863
- 4. **The bound is measured from when the attempt started**, not from when config was read. `tests.setupTimeout` is only readable after `loadConfigFiles()`, which happens *inside* the window being bounded; re-arming naively would give you the default plus your configured value.
5993
+ 4. **The bound is measured from when the attempt started**, not from when config was read. `tests.setupTimeout` is only readable after `loadConfigFiles()`, which happens _inside_ the window being bounded; re-arming naively would give you the default plus your configured value.
5864
5994
 
5865
- ⚠ **What is proven and what is not.** The nine guards above were each seen to fail under their own mutation. **But every spec injects its scheduler**, so the default *value* is tested while the production timer — and whether its `unref` actually releases the worker — is not. **And no spec observes a real hang**: the stuck attempt is a mock gate, not a socket that never returns. These prove what the lifecycle *decides*, not what a genuinely wedged connector does.
5995
+ ⚠ **What is proven and what is not.** The nine guards above were each seen to fail under their own mutation. **But every spec injects its scheduler**, so the default _value_ is tested while the production timer — and whether its `unref` actually releases the worker — is not. **And no spec observes a real hang**: the stuck attempt is a mock gate, not a socket that never returns. These prove what the lifecycle _decides_, not what a genuinely wedged connector does.
5866
5996
 
5867
5997
  ### State is per worker runtime, not per module
5868
5998
 
@@ -5893,6 +6023,16 @@ In both cases the guard's scope matches the resource's scope, which is the point
5893
6023
  - [`warlock-conventions/SKILL.md`](../warlock-conventions/SKILL.md) — where tests live in a module (`tests/*.test.ts`).
5894
6024
  - [`write-cli-command/SKILL.md`](../write-cli-command/SKILL.md) — `warlock add test` for the initial scaffold.
5895
6025
 
6026
+ ## App modules and `importModule`
6027
+
6028
+ Under Vitest, test files run through vite-node while Warlock's own native `import()` of app modules (config, routes, events) does not, so app classes such as `User` can exist twice in one worker and `instanceof` fails. Pass `importModule` to `setupTest` so Warlock loads app modules through the test's own module graph:
6029
+
6030
+ ```ts
6031
+ setupTest({ importModule: (file) => import(file) });
6032
+ ```
6033
+
6034
+ The generated `src/test-setup.ts` already does this. Without it, `setupTest()` under Vitest warns once.
6035
+
5896
6036
 
5897
6037
  ## update-packages `@warlock.js/core/update-packages/SKILL.md`
5898
6038
 
@@ -6654,7 +6794,7 @@ cors: {
6654
6794
 
6655
6795
  ---
6656
6796
  name: use-localization
6657
- description: 'Multi-locale translations via `groupedTranslations` (declare keys), `t()` / `request.t()` / `request.trans()` (look up), `request.getLocaleCode()` (detect locale from headers/query), `getLocalized` (pick the right value from a localized-array column). Triggers: `groupedTranslations`, `t`, `request.t`, `request.trans`, `request.transFrom`, `request.getLocaleCode`, `request.setLocaleCode`, `getLocalized`; "add a translation key", "resolve a localized error message", "detect request locale", "pick the right per-locale column value"; typical import `import { t, getLocalized } from "@warlock.js/core"`. Skip: resource output — `@warlock.js/core/define-resource/SKILL.md`; module scaffold — `@warlock.js/core/create-module/SKILL.md`; competing libs `i18next`, `react-intl`, raw `@mongez/localization`.'
6797
+ description: 'Multi-locale translations via `groupedTranslations` (declare keys), `t()` (look up; always use it in app code, never `request.t()`), `request.getLocaleCode()` (detect locale from headers/query), `getLocalized` (pick the right value from a localized-array column). Triggers: `groupedTranslations`, `t`, `request.t`, `request.trans`, `request.transFrom`, `request.getLocaleCode`, `request.setLocaleCode`, `getLocalized`; "add a translation key", "resolve a localized error message", "detect request locale", "pick the right per-locale column value"; typical import `import { t, getLocalized } from "@warlock.js/core"`. Skip: resource output — `@warlock.js/core/define-resource/SKILL.md`; module scaffold — `@warlock.js/core/create-module/SKILL.md`; competing libs `i18next`, `react-intl`, raw `@mongez/localization`.'
6658
6798
  ---
6659
6799
 
6660
6800
  # Warlock — translate keys + pick localized values
@@ -6669,7 +6809,7 @@ Both pivot on **the request's locale** — auto-detected from headers / query st
6669
6809
  ## The shape
6670
6810
 
6671
6811
  ```ts
6672
- // 1. Declare keys (auto-loaded from src/app/<module>/utils/locales.ts)
6812
+ // 1. Declare keys (auto-loaded from src/app/<module>/utils/locales.json)
6673
6813
  import { groupedTranslations } from "@mongez/localization";
6674
6814
 
6675
6815
  groupedTranslations("products", {
@@ -6681,8 +6821,7 @@ groupedTranslations("products", {
6681
6821
  import { t } from "@warlock.js/core";
6682
6822
 
6683
6823
  throw new ResourceNotFoundError(t("products.notFound"));
6684
- // or via the request:
6685
- return response.success({ message: request.t("products.created") });
6824
+ return response.success({ message: t("products.created") });
6686
6825
 
6687
6826
  // 3. Pick a value from a localized-array column
6688
6827
  import { getLocalized } from "@warlock.js/core";
@@ -6691,7 +6830,26 @@ const name = getLocalized(product.get("name_translations"));
6691
6830
  // → reads the current request's locale, returns the matching value
6692
6831
  ```
6693
6832
 
6694
- ## Declaring translations — `groupedTranslations`
6833
+ ## Declaring translations — module `locales.json`
6834
+
6835
+ Prefer a data-only dictionary in `src/app/<module>/utils/locales.json`. The
6836
+ module folder is its default group, so this example registers
6837
+ `products.notFound`; a root `$group` string replaces that group when a module
6838
+ needs a different namespace. The module loader validates the strict JSON
6839
+ shape, flattens nested keys, and rejects duplicate ownership. Do not import the
6840
+ file yourself.
6841
+
6842
+ ```json title="src/app/products/utils/locales.json"
6843
+ {
6844
+ "notFound": { "en": "Product not found", "ar": "المنتج غير موجود" },
6845
+ "errors": { "outOfStock": { "en": "Product out of stock", "ar": "المنتج غير متوفر" } }
6846
+ }
6847
+ ```
6848
+
6849
+ `utils/locales.ts` with `groupedTranslations` remains supported but is
6850
+ deprecated and will be removed in v6. Migrate it with `warlock doctor --fix`.
6851
+
6852
+ ### Legacy `groupedTranslations`
6695
6853
 
6696
6854
  Every module owns its translation namespace under `src/app/<module>/utils/locales.ts`. The file is auto-loaded at boot — you don't import it from anywhere; the framework picks it up via the module loader.
6697
6855
 
@@ -6728,31 +6886,25 @@ t("products.outOfStock", { name: "Pen", count: 0 });
6728
6886
 
6729
6887
  Placeholders use the `:name` syntax. The lookup helper substitutes them from the second argument.
6730
6888
 
6731
- ## Looking up translations — `t()`, `request.t()`, `request.trans()`
6889
+ ## Looking up translations — always `t()`
6732
6890
 
6733
- Three calls, same job, slightly different reachability:
6891
+ **Rule: app code translates with `t()` from `@warlock.js/core`.** It works everywhere: controllers, services, page and layout loaders, `metadata`, events and jobs. It returns a `string`.
6734
6892
 
6735
6893
  ```ts
6736
- // 1. Top-level helper — works inside or outside a request.
6737
- // Inside a request: uses request's locale.
6738
- // Outside: uses the global default locale.
6739
6894
  import { t } from "@warlock.js/core";
6740
6895
 
6741
6896
  t("products.notFound");
6742
6897
  t("products.outOfStock", { name: "Pen", count: 0 });
6743
-
6744
- // 2. On the request object — explicit, scoped to that request.
6745
- request.t("products.notFound");
6746
-
6747
- // 3. Alias for request.t — same behavior.
6748
- request.trans("products.notFound");
6749
6898
  ```
6750
6899
 
6751
- All three lookups go through `@mongez/localization`'s `trans()` under the hood, with the locale pulled from the request context (or the global default).
6900
+ - Inside a request (a controller or a page loader) it uses the request's locale. In a page it first checks the route's `locales.json` snapshot, then falls back to module translations (`groupedTranslations` or module `locales.json`).
6901
+ - Outside a request (jobs, CLI) it uses the configured default locale.
6902
+ - `request.t()` / `request.trans()` are the per-request binding that `t()` delegates to. Don't call them in app code: `t()` gives the same result without threading `request` through services.
6903
+ - Don't wrap it in `String(...)`; it is already typed `string`.
6752
6904
 
6753
6905
  ### Web `useTrans()` key checking
6754
6906
 
6755
- When an app uses `@warlock.js/web`, `warlock dev` writes `.warlock/typings/translations.d.ts` from literal `groupedTranslations("group", { key: ... })` registrations. It augments web's `TranslationKeyRegistry`, so `useTrans()("products.notFound")` is checked against registered keys and a typo fails TypeScript. Before the generated file exists, `useTrans()` accepts `string` for a non-breaking first boot. Dynamic groups/keys and placeholders are not inferred.
6907
+ When an app uses `@warlock.js/web`, `warlock dev` writes `.warlock/typings/translations.d.ts` from module `locales.json` dictionaries and literal `groupedTranslations("group", { key: ... })` registrations. It augments web's `TranslationKeyRegistry`, so `useTrans()("products.notFound")` is checked against registered keys and a typo fails TypeScript. Before the generated file exists, `useTrans()` accepts `string` for a non-breaking first boot. Dynamic TypeScript groups/keys and placeholders are not inferred.
6756
6908
 
6757
6909
  `useTrans()` now works correctly across hydration (5.15.0): the hydration
6758
6910
  payload ships a `translations` key with the active locale's keywords, and
@@ -6798,7 +6950,7 @@ export default {
6798
6950
  request.setLocaleCode("ar");
6799
6951
  ```
6800
6952
 
6801
- After this, every `request.t(...)` and `getLocalized(...)` in the same request returns Arabic. Useful for user-preference overrides (e.g. "this user has set their language to ar in their profile — switch the request's locale on auth").
6953
+ After this, every `t(...)` and `getLocalized(...)` in the same request returns Arabic. Useful for user-preference overrides (e.g. "this user has set their language to ar in their profile — switch the request's locale on auth").
6802
6954
 
6803
6955
  ## Localized columns — `getLocalized`
6804
6956
 
@@ -6895,6 +7047,7 @@ The error's message is locale-aware — the `inject-request-context` middleware
6895
7047
  import { type GuardedRequestHandler } from "app/auth/requests/guarded.request";
6896
7048
  import { type CreateProductSchema } from "../schema/create-product.schema";
6897
7049
  import { createProductService } from "../services/create-product.service";
7050
+ import { t } from "@warlock.js/core";
6898
7051
 
6899
7052
  export const createProductController: GuardedRequestHandler<CreateProductSchema> = async ({
6900
7053
  request,
@@ -6902,7 +7055,7 @@ export const createProductController: GuardedRequestHandler<CreateProductSchema>
6902
7055
  }) => {
6903
7056
  const product = await createProductService(request.validated());
6904
7057
  return response.success({
6905
- message: request.t("products.created"),
7058
+ message: t("products.created"),
6906
7059
  product,
6907
7060
  });
6908
7061
  };
@@ -6942,11 +7095,11 @@ await Product.create({
6942
7095
  GET /products/42?locale=ar
6943
7096
  ```
6944
7097
 
6945
- `request.getLocaleCode()` returns `"ar"` — every `request.t(...)` and resource-output `getLocalized` returns Arabic. No app code changes required.
7098
+ `request.getLocaleCode()` returns `"ar"` — every `t(...)` and resource-output `getLocalized` returns Arabic. No app code changes required.
6946
7099
 
6947
7100
  ## Gotchas
6948
7101
 
6949
- - **Locales files are auto-loaded — don't import them.** `src/app/<module>/utils/locales.ts` is picked up by the module loader on boot. Importing it manually causes double-registration warnings.
7102
+ - **Locales files are auto-loaded — don't import them.** `src/app/<module>/utils/locales.json` is picked up by the module loader on boot. Importing legacy `locales.ts` manually causes double-registration warnings.
6950
7103
  - **Group names should match module URL slugs.** `groupedTranslations("products", ...)` for the `/products` module. Mismatch breaks the convention but doesn't throw.
6951
7104
  - **Every key needs every locale.** If a key is missing for the active locale, the lookup falls back to the key string itself (`"products.notFound"` literally appears in the response). Always ship all locales together when adding a key.
6952
7105
  - **`t()` outside a request uses the global default.** `Application.environment === "test"` or background jobs without a request context get the configured `app.localeCode`, not whatever the most recent request used.
@@ -6959,8 +7112,8 @@ GET /products/42?locale=ar
6959
7112
 
6960
7113
  - [`send-response/SKILL.md`](../send-response/SKILL.md) — error helpers that pair with translated messages (`response.notFound({ error: t("...") })`).
6961
7114
  - [`define-resource/SKILL.md`](../define-resource/SKILL.md) — using `getLocalized` inside resource output for clean per-locale responses.
6962
- - [`create-module/SKILL.md`](../create-module/SKILL.md) — the `utils/locales.ts` file is part of the generated module scaffold.
6963
- - [`warlock-conventions/SKILL.md`](../warlock-conventions/SKILL.md) — `utils/locales.ts` is auto-loaded; the suffix is mandatory.
7115
+ - [`create-module/SKILL.md`](../create-module/SKILL.md) — the `utils/locales.json` file is part of the generated module scaffold.
7116
+ - [`warlock-conventions/SKILL.md`](../warlock-conventions/SKILL.md) — `utils/locales.json` is auto-loaded; the suffix is mandatory.
6964
7117
 
6965
7118
  ## Route-owned web translations (5.17)
6966
7119
 
@@ -6974,9 +7127,9 @@ GET /products/42?locale=ar
6974
7127
  }
6975
7128
  ```
6976
7129
 
6977
- This produces `account.title` and `account.actions.save`. Keep every leaf complete for the configured locale list. The page pipeline selects an immutable snapshot for its route source and locale. `useTrans()`, `request.t()`, and `request.trans()` use that snapshot; a missing scoped key returns the key itself. With no route-locales manifest, the existing global `@mongez/localization` registry remains the legacy fallback path.
7130
+ This produces `account.title` and `account.actions.save`. Keep every leaf complete for the configured locale list. The page pipeline selects an immutable snapshot for its route source and locale. `useTrans()` and `t()` use that snapshot; a missing scoped key returns the key itself. With no route-locales manifest, the existing global `@mongez/localization` registry remains the legacy fallback path.
6978
7131
 
6979
- When a route-locales snapshot applies, `request.t()`, `request.trans()`, and `request.transFrom(locale, key, placeholders?)` resolve through it; without one, they retain the existing global-registry behavior. The underlying `transFromKeywords(locale, keywords, key, placeholders?, converter?)` is supplied by `@mongez/localization` 3.5.0. Error pages rebind to their own snapshot, while framework fallback uses the app/root snapshot. Development HMR add/edit/delete publishes a new snapshot, and the snapshot revision keeps page-cache entries from reusing old JSON. Generated `.warlock` typings contain the flattened route keys; do not maintain a second handwritten key union.
7132
+ When a route-locales snapshot applies, `t()` (through the request binding) and `request.transFrom(locale, key, placeholders?)` resolve through it; without one, they retain the existing global-registry behavior. The underlying `transFromKeywords(locale, keywords, key, placeholders?, converter?)` is supplied by `@mongez/localization` 3.5.0. Error pages rebind to their own snapshot, while framework fallback uses the app/root snapshot. Development HMR add/edit/delete publishes a new snapshot, and the snapshot revision keeps page-cache entries from reusing old JSON. Generated `.warlock` typings contain the flattened route keys; do not maintain a second handwritten key union.
6980
7133
 
6981
7134
 
6982
7135
  ## use-middleware `@warlock.js/core/use-middleware/SKILL.md`
@@ -8417,6 +8570,9 @@ This skill is the foundation. Every other warlock skill (`register-route`, `crea
8417
8570
  2. **Several files are auto-loaded — never `import` them.** The dev-server file watcher and the production builder categorize these as "special" and load them on boot.
8418
8571
  - `src/app/<module>/routes.ts` — route declarations
8419
8572
  - `src/app/<module>/main.ts` — per-module one-time setup
8573
+ - `src/app/<module>/worker.ts` — worker-only setup (queue job definitions,
8574
+ `scheduler.addJob(...)`, `scheduler.start()`); loaded only for the
8575
+ `worker` role, and always loaded by `warlock dev`
8420
8576
  - `src/app/main.ts` — **project-level** one-time application setup (global hooks and other app initialization; connector instances belong in `warlock.config.ts > connectors`)
8421
8577
  - `src/app/<module>/events/*.ts(x)` — **any** `.ts(x)` file inside the `events/` folder (the `*.event.ts` suffix is convention, not a framework requirement)
8422
8578
  - `src/app/<module>/utils/locales.ts` — module translations via `groupedTranslations(...)`
@@ -8437,7 +8593,7 @@ This skill is the foundation. Every other warlock skill (`register-route`, `crea
8437
8593
  ```ts title="src/app/<module>/schema/create-<thing>.schema.ts"
8438
8594
  import { v, type Infer } from "@warlock.js/seal";
8439
8595
 
8440
- export const createThingSchema = v.object({ /* … */ });
8596
+ export const createThingSchema = v.object({/* … */});
8441
8597
  export type CreateThingSchema = Infer<typeof createThingSchema>;
8442
8598
  ```
8443
8599
 
@@ -8446,13 +8602,17 @@ This skill is the foundation. Every other warlock skill (`register-route`, `crea
8446
8602
  import { type CreateThingSchema, createThingSchema } from "../schema/create-thing.schema";
8447
8603
  import { createThingService } from "../services/create-thing.service";
8448
8604
 
8449
- export const createThingController: GuardedRequestHandler<CreateThingSchema> = async ({ request, response }) => {
8605
+ export const createThingController: GuardedRequestHandler<CreateThingSchema> = async ({
8606
+ request,
8607
+ response,
8608
+ }) => {
8450
8609
  const thing = await createThingService(request.validated());
8451
8610
  return response.success({ thing });
8452
8611
  };
8453
8612
 
8454
8613
  createThingController.validation = { schema: createThingSchema };
8455
8614
  ```
8615
+
8456
8616
  8. **Model getters beat `.get<T>("field")`.** Add a typed getter on the model rather than scattering `.get<string>("name")` casts across call sites.
8457
8617
 
8458
8618
  ## Module layout (the standard subfolders)
@@ -8471,26 +8631,27 @@ src/app/<module>/
8471
8631
  seeds/ seed data for `warlock seed`
8472
8632
  routes.ts ← auto-loaded
8473
8633
  main.ts ← auto-loaded once, one-time setup
8634
+ worker.ts ← auto-loaded for the worker role only
8474
8635
  ```
8475
8636
 
8476
8637
  Some older modules still have a `validation/` folder instead of `schema/` — historical drift. The framework treats them as plain folders either way; the generator emits to `schema/`.
8477
8638
 
8478
8639
  ## File naming (project-wide)
8479
8640
 
8480
- | Suffix | Role | Example |
8481
- | ------------------- | ------------------------------------- | ----------------------------------- |
8482
- | `.controller.ts` | HTTP handler | `create-product.controller.ts` |
8483
- | `.service.ts` | Stateless business logic | `create-product.service.ts` |
8484
- | `.usecase.ts` | `useCase()` pipeline | `login.usecase.ts` |
8485
- | `.model.ts` | Cascade model class | `product.model.ts` |
8486
- | `.repository.ts` | `RepositoryManager` subclass | `products.repository.ts` |
8487
- | `.resource.ts` | `Resource` subclass | `product.resource.ts` |
8488
- | `.schema.ts` | seal validation schema + its inferred `<Name>Schema` type from the same file | `create-product.schema.ts` |
8489
- | `.type.ts` | data shape (TypeScript `type`) | `cart-state.type.ts` |
8490
- | `.contract.ts` | interface (TypeScript `interface`) | `model.contract.ts` |
8491
- | `.event.ts` | event listener registrations (any `.ts(x)` inside `events/` works; the suffix is convention) | `audit.event.ts` |
8492
- | `.migration.ts` | Cascade migration | `2026_05_22_120000_product.migration.ts` |
8493
- | `.seed.ts` | seed data | `products.seed.ts` |
8641
+ | Suffix | Role | Example |
8642
+ | ---------------- | -------------------------------------------------------------------------------------------- | ---------------------------------------- |
8643
+ | `.controller.ts` | HTTP handler | `create-product.controller.ts` |
8644
+ | `.service.ts` | Stateless business logic | `create-product.service.ts` |
8645
+ | `.usecase.ts` | `useCase()` pipeline | `login.usecase.ts` |
8646
+ | `.model.ts` | Cascade model class | `product.model.ts` |
8647
+ | `.repository.ts` | `RepositoryManager` subclass | `products.repository.ts` |
8648
+ | `.resource.ts` | `Resource` subclass | `product.resource.ts` |
8649
+ | `.schema.ts` | seal validation schema + its inferred `<Name>Schema` type from the same file | `create-product.schema.ts` |
8650
+ | `.type.ts` | data shape (TypeScript `type`) | `cart-state.type.ts` |
8651
+ | `.contract.ts` | interface (TypeScript `interface`) | `model.contract.ts` |
8652
+ | `.event.ts` | event listener registrations (any `.ts(x)` inside `events/` works; the suffix is convention) | `audit.event.ts` |
8653
+ | `.migration.ts` | Cascade migration | `2026_05_22_120000_product.migration.ts` |
8654
+ | `.seed.ts` | seed data | `products.seed.ts` |
8494
8655
 
8495
8656
  Filenames are kebab-case. Class names are PascalCase. Function names are camelCase. Table/collection names are snake_case (plural).
8496
8657
 
@@ -8501,8 +8662,8 @@ The scaffolded `tsconfig.json` defines:
8501
8662
  ```jsonc
8502
8663
  {
8503
8664
  "paths": {
8504
- "app/*": ["./src/app/*"]
8505
- }
8665
+ "app/*": ["./src/app/*"],
8666
+ },
8506
8667
  }
8507
8668
  ```
8508
8669
 
@@ -9147,16 +9308,16 @@ Run it: `npx warlock users.promote --email=hasan@example.com` (or `npx warlock u
9147
9308
 
9148
9309
  ## `CLICommandOptions` — the factory input
9149
9310
 
9150
- | Field | Type | Required | Notes |
9151
- | ------------- | ----------------------------- | -------- | ---------------------------------------------------------------------------------------------- |
9152
- | `name` | `string` | yes | Dot notation OK (`db.seed`, `jwt.generate`). May include positional placeholders (`name <arg>`). |
9153
- | `description` | `string` | | Shown in `warlock --help` and `warlock <cmd> --help`. |
9154
- | `alias` | `string` | | Short name (`m` for `migrate`). |
9155
- | `action` | `(data) => void \| Promise` | yes | Runs after preloaders. `data` is `{ args, options }`. |
9156
- | `preAction` | `(data) => void \| Promise` | | Runs **before** preloaders — banner, input validation. |
9157
- | `preload` | `CLICommandPreload` | | What to load before `action` runs. See below. |
9158
- | `persistent` | `boolean` | | `true` for long-running commands (dev server). Skips the auto-exit. |
9159
- | `options` | `CLICommandOption[]` | | Flag definitions. See below. |
9311
+ | Field | Type | Required | Notes |
9312
+ | ------------- | --------------------------- | -------- | ------------------------------------------------------------------------------------------------ |
9313
+ | `name` | `string` | yes | Dot notation OK (`db.seed`, `jwt.generate`). May include positional placeholders (`name <arg>`). |
9314
+ | `description` | `string` | | Shown in `warlock --help` and `warlock <cmd> --help`. |
9315
+ | `alias` | `string` | | Short name (`m` for `migrate`). |
9316
+ | `action` | `(data) => void \| Promise` | yes | Runs after preloaders. `data` is `{ args, options }`. |
9317
+ | `preAction` | `(data) => void \| Promise` | | Runs **before** preloaders — banner, input validation. |
9318
+ | `preload` | `CLICommandPreload` | | What to load before `action` runs. See below. |
9319
+ | `persistent` | `boolean` | | `true` for long-running commands (dev server). Skips the auto-exit. |
9320
+ | `options` | `CLICommandOption[]` | | Flag definitions. See below. |
9160
9321
 
9161
9322
  ## Options — flag shape
9162
9323
 
@@ -9258,29 +9419,29 @@ The framework ships a fixed set of commands you call but don't author. Knowing t
9258
9419
 
9259
9420
  ### Database + migrations
9260
9421
 
9261
- | Command | Flags / args | Preloads |
9262
- | -------- | ----------------------------------------------- | ------------------------------ |
9263
- | `warlock migrate` | `--list` / `-l` (executed **and** pending), `--pending` (pending only, sets an exit code), `--fresh` / `-f` (drop tables first) | database, logger |
9264
- | `warlock seed` | `--name <pattern>` (run seeds matching the pattern) | full bootstrap (env, configs, app modules) |
9265
- | `warlock create-database <name>` | bare positional `<name>` | database |
9266
- | `warlock drop.tables` | `--force, -f` (skip confirmation prompt) | database, logger |
9267
- | `warlock db.indexes` | builds DB indexes for every registered model | database |
9422
+ | Command | Flags / args | Preloads |
9423
+ | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
9424
+ | `warlock migrate` | `--list` / `-l` (executed **and** pending), `--pending` (pending only, sets an exit code), `--fresh` / `-f` (drop tables first) | database, logger |
9425
+ | `warlock seed` | `--name <pattern>` (run seeds matching the pattern) | full bootstrap (env, configs, app modules) |
9426
+ | `warlock create-database <name>` | bare positional `<name>` | database |
9427
+ | `warlock drop.tables` | `--force, -f` (skip confirmation prompt) | database, logger |
9428
+ | `warlock db.indexes` | builds DB indexes for every registered model | database |
9268
9429
 
9269
9430
  **Asking what will run next.** `warlock migrate --list` prints executed migrations and then the pending ones **in execution order**. Do not derive the pending set by differencing `--all` against `--list`: `--all` globs `src/app` only, while `--list` reads the migrations table, which also holds migrations that packages register through `database.migrations` (`@warlock.js/auth` contributes two). The difference under-counts pending, in the direction that says "safe to proceed".
9270
9431
 
9271
9432
  `--list` is a report and always exits `0`. `--pending` is a gate, and its exit code is its whole API:
9272
9433
 
9273
- | Exit | Meaning |
9274
- | ---- | ------- |
9434
+ | Exit | Meaning |
9435
+ | ---- | ------------------------- |
9275
9436
  | `0` | computed, nothing pending |
9276
- | `1` | computed, N pending |
9437
+ | `1` | computed, N pending |
9277
9438
  | `2` | **could not be computed** |
9278
9439
 
9279
9440
  ```bash
9280
9441
  warlock migrate --pending && ./deploy.sh
9281
9442
  ```
9282
9443
 
9283
- `2` is separate from `1` on purpose — a script must be able to tell a backlog from an unknown, because one means *run them* and the other means *stop*. When the migration files cannot be loaded, both commands print `Pending: unavailable — <reason>` beneath a complete executed listing rather than reporting `0`.
9444
+ `2` is separate from `1` on purpose — a script must be able to tell a backlog from an unknown, because one means _run them_ and the other means _stop_. When the migration files cannot be loaded, both commands print `Pending: unavailable — <reason>` beneath a complete executed listing rather than reporting `0`.
9284
9445
 
9285
9446
  **If you are writing a command that reports on pending migrations:** register migrations first. `listPendingMigrations()` filters the runner's registry, so a caller that has not loaded anything gets `[]` — which reads as "nothing pending" and is not the same claim.
9286
9447
 
@@ -9306,36 +9467,41 @@ warlock generate (alias: g) — interactive picker
9306
9467
 
9307
9468
  `warlock add <features...>` installs framework-adjacent features through a curated registry — bundles npm dependencies + scaffold files + tsconfig patches + package.json scripts in one call. Featured names (from `add-command.action.ts`):
9308
9469
 
9309
- | Feature | Installs |
9310
- | ------------- | --------------------------------------------------------------------------------------------------- |
9311
- | `react-email` | `react-email` + `@react-email/components` + `@react-email/render` + `@react-email/tailwind`; drops a `welcome-email.tsx` sample; patches `tsconfig.json` |
9312
- | `web` | Warlock Web + React stack; scaffolds the application page, localization, contact endpoint, and Web configuration |
9313
- | `react` | `react` + `react-dom` + types |
9470
+ | Feature | Installs |
9471
+ | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
9472
+ | `react-email` | `react-email` + `@react-email/components` + `@react-email/render` + `@react-email/tailwind`; drops a `welcome-email.tsx` sample; patches `tsconfig.json` |
9473
+ | `web` | Warlock Web + React stack; scaffolds the application page, localization, contact endpoint, and Web configuration |
9474
+ | `react` | `react` + `react-dom` + types |
9314
9475
  | `bull-board` | `@bull-board/api` + `@bull-board/fastify`; writes a `dashboard` block to `src/config/queue.ts`. `requires: ["queue"]` — adds the `queue` feature first automatically when it's missing |
9315
- | `image` | `sharp` (for the `Image` class) |
9316
- | `mail` | `nodemailer` + types |
9317
- | `ses` | `@aws-sdk/client-sesv2` |
9318
- | `mongodb` | `mongodb` driver |
9319
- | `postgres` | `pg` |
9320
- | `mysql` | `mysql2` |
9321
- | `redis` | `redis` |
9322
- | `s3` | `@aws-sdk/client-s3` + `@aws-sdk/lib-storage` + `@aws-sdk/s3-request-presigner` |
9323
- | `scheduler` | `@warlock.js/scheduler` |
9324
- | `swagger` | `@warlock.js/swagger` |
9325
- | `postman` | `@warlock.js/postman` |
9326
- | `herald` | `@warlock.js/herald` + `amqplib` + types; ejects `src/config/communicator.ts` |
9327
- | `test` | `vitest` + `@mongez/vite` + coverage; drops `test-global-setup.ts` / `test-setup.ts` / `vite.config.ts` |
9476
+ | `image` | `sharp` (for the `Image` class) |
9477
+ | `mail` | `nodemailer` + types |
9478
+ | `ses` | `@aws-sdk/client-sesv2` |
9479
+ | `mongodb` | `mongodb` driver |
9480
+ | `postgres` | `pg` |
9481
+ | `mysql` | `mysql2` |
9482
+ | `redis` | `redis` |
9483
+ | `s3` | `@aws-sdk/client-s3` + `@aws-sdk/lib-storage` + `@aws-sdk/s3-request-presigner` |
9484
+ | `scheduler` | `@warlock.js/scheduler` |
9485
+ | `swagger` | `@warlock.js/swagger` |
9486
+ | `postman` | `@warlock.js/postman` |
9487
+ | `herald` | `@warlock.js/herald` + `amqplib` + types; ejects `src/config/communicator.ts` |
9488
+ | `test` | `vitest` + `@mongez/vite` + coverage; drops `test-global-setup.ts` / `test-setup.ts` / `vite.config.ts` |
9489
+ | `devtools` | `@warlock.js/devtools` as a dev dependency; `warlock dev` auto-loads its development dashboard at `/__warlock` |
9328
9490
 
9329
9491
  Run `warlock add --list` to see what's currently registered. Pass `--packageManager <yarn\|pnpm\|npm>` to override auto-detection (defaults to whichever lockfile is present).
9330
9492
 
9493
+ `warlock add devtools` never adds anything to `warlock.config.ts`. The package
9494
+ is loaded automatically by `warlock dev` when installed, and is omitted from a
9495
+ production install with dev dependencies excluded.
9496
+
9331
9497
  The Web starter keeps the page's universal non-component setup in `src/web/index.register.ts`, while `src/web/index.page.tsx` re-exports `register`. Warlock still discovers and runs the hook from the page namespace in both SSR and the browser, but editing the page component remains inside React's Fast Refresh boundary. Do not move the `register()` declaration inline beside the component: React Refresh treats that non-component export as incompatible and reloads the page, losing component state.
9332
9498
 
9333
9499
  ### Misc
9334
9500
 
9335
- | Command | Purpose |
9336
- | -------- | ----------------------------------------------------------- |
9337
- | `warlock storage.put <localPath> [destination]` | Upload a local file to the default storage disk |
9338
- | `warlock jwt.generate` | Generate a JWT secret in `.env` (from `@warlock.js/auth`) |
9501
+ | Command | Purpose |
9502
+ | ----------------------------------------------- | --------------------------------------------------------- |
9503
+ | `warlock storage.put <localPath> [destination]` | Upload a local file to the default storage disk |
9504
+ | `warlock jwt.generate` | Generate a JWT secret in `.env` (from `@warlock.js/auth`) |
9339
9505
 
9340
9506
  ## Common patterns
9341
9507
 
@@ -9397,7 +9563,7 @@ export default command({
9397
9563
  });
9398
9564
  ```
9399
9565
 
9400
- `preAction` runs *before* preloaders — cheap way to bail out without spinning up the database.
9566
+ `preAction` runs _before_ preloaders — cheap way to bail out without spinning up the database.
9401
9567
 
9402
9568
  ## Gotchas
9403
9569
 
@@ -9405,7 +9571,7 @@ export default command({
9405
9571
  - **Don't put logic at module top level.** The file gets imported during the commands scan (`warlock --warm-cache`). Anything outside `action` runs at scan time, possibly before any config or env is loaded.
9406
9572
  - **Required options block execution.** If `required: true` and the user omits the flag, the framework prints `Missing required options:` and exits `1` before `action` runs.
9407
9573
  - **Connectors are not free.** `connectors: true` boots the database, cache, storage, etc. For a print-version-and-exit command, leave `preload` undefined.
9408
- - **`name` field with positional slots** (`name: "storage.put <localPath>"`) — the *registered* name is still `storage.put` (the first whitespace-separated token), so look up + alias work normally. Slots are only documentation/help-output.
9574
+ - **`name` field with positional slots** (`name: "storage.put <localPath>"`) — the _registered_ name is still `storage.put` (the first whitespace-separated token), so look up + alias work normally. Slots are only documentation/help-output.
9409
9575
  - **Aliases must be unique across plugin + framework + project.** The first registration wins; later collisions silently overwrite the map entry.
9410
9576
 
9411
9577
  ## See also
@@ -10302,6 +10468,8 @@ Per-invocation callbacks fire first, then use-case-level, then globals. The retu
10302
10468
 
10303
10469
  Don't reach for `useCase` for a 5-line service — the ceremony outweighs the benefit.
10304
10470
 
10471
+ For the plain-function flavour (`warlock generate.use-case <module>/<verb-noun>`) shared by a page action and a controller, see the guide [One use-case, two adapters](https://warlock.js.org/v/latest/core/guides/one-use-case-two-adapters/).
10472
+
10305
10473
  ## Gotchas
10306
10474
 
10307
10475
  - **`name` must be unique.** The registry de-dupes by name; a duplicate in dev logs a warning and one wins. Use namespaced names: `"orders.placeOrder"`.