@dunx/create-app 2.3.1 → 2.5.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 (42) hide show
  1. package/dist/{chunk-yzz4z6jv.js → chunk-nn9ekg83.js} +110 -16
  2. package/dist/cli.js +1 -4
  3. package/dist/index.js +1 -4
  4. package/package.json +2 -2
  5. package/templates/features/assets/assets.demo.ts +41 -0
  6. package/templates/features/assets/assets.module.ts +37 -0
  7. package/templates/features/assets/public/app.a1b2c3d4.js +1 -0
  8. package/templates/features/assets/public/index.html +9 -0
  9. package/templates/features/assets/public/site.css +3 -0
  10. package/templates/features/auth/auth.module.ts +15 -4
  11. package/templates/features/cache/cache.controller.ts +5 -2
  12. package/templates/features/database/ledger.controller.ts +7 -4
  13. package/templates/features/guards/reports.controller.ts +11 -2
  14. package/templates/features/health/health.demo.ts +72 -0
  15. package/templates/features/health/health.module.ts +70 -9
  16. package/templates/features/health/indicators.ts +98 -0
  17. package/templates/features/http/compression.demo.ts +88 -0
  18. package/templates/features/http/http.demo.ts +6 -4
  19. package/templates/features/http/http.module.ts +31 -3
  20. package/templates/features/http/{request-log.ts → request-trail.ts} +10 -11
  21. package/templates/features/http/trace.controller.ts +30 -0
  22. package/templates/features/http/trace.demo.ts +58 -0
  23. package/templates/features/jobs/jobs.controller.ts +7 -2
  24. package/templates/features/jobs/thumbnail.jobs.ts +7 -1
  25. package/templates/features/notes/notes.controller.ts +1 -1
  26. package/templates/features/pictures/images.controller.ts +5 -2
  27. package/templates/features/schedule/maintenance.service.ts +71 -0
  28. package/templates/features/schedule/schedule.demo.ts +56 -0
  29. package/templates/features/schedule/schedule.module.ts +31 -0
  30. package/templates/features/storage/files.controller.ts +5 -4
  31. package/templates/features/throttle/limits.controller.ts +35 -0
  32. package/templates/features/throttle/throttle.demo.ts +74 -0
  33. package/templates/features/throttle/throttle.module.ts +88 -0
  34. package/templates/features/upstream/flaky.controller.ts +48 -0
  35. package/templates/features/upstream/upstream.demo.ts +83 -0
  36. package/templates/features/upstream/upstream.module.ts +42 -0
  37. package/templates/features/users/users.controller.ts +8 -0
  38. package/templates/features/users/users.schemas.ts +30 -11
  39. package/dist/chunk-yzz4z6jv.js.map +0 -12
  40. package/dist/cli.js.map +0 -10
  41. package/dist/index.js.map +0 -9
  42. package/templates/features/health/health.controller.ts +0 -65
@@ -82,6 +82,37 @@ var CONFIG_GROUPS = Object.freeze({
82
82
  field: "readonly authorization: { readonly enabled: boolean };",
83
83
  map: "authorization: { enabled: true },",
84
84
  env: []
85
+ },
86
+ throttle: {
87
+ schema: [
88
+ "/** The app-wide limit. Generous, so a per-route `@Throttle` is the interesting half. */",
89
+ "THROTTLE_LIMIT: z.coerce.number().int().min(1).default(1000),",
90
+ "THROTTLE_WINDOW_SECONDS: z.coerce.number().int().min(1).default(60),"
91
+ ],
92
+ field: "readonly throttle: { readonly limit: number; readonly windowSeconds: number };",
93
+ map: "throttle: { limit: value.THROTTLE_LIMIT, windowSeconds: value.THROTTLE_WINDOW_SECONDS },",
94
+ env: [
95
+ { name: "THROTTLE_LIMIT", value: "1000" },
96
+ { name: "THROTTLE_WINDOW_SECONDS", value: "60" }
97
+ ]
98
+ },
99
+ schedule: {
100
+ schema: [
101
+ "/** A `@Cron` that names no zone of its own runs in this one. */",
102
+ "SCHEDULE_TZ: z.string().default('UTC'),"
103
+ ],
104
+ field: "readonly schedule: { readonly tz: string };",
105
+ map: "schedule: { tz: value.SCHEDULE_TZ },",
106
+ env: [{ name: "SCHEDULE_TZ", value: "UTC" }]
107
+ },
108
+ upstream: {
109
+ schema: [
110
+ "/** Per-call budget for the outbound client. */",
111
+ "UPSTREAM_TIMEOUT_MS: z.coerce.number().int().min(1).default(5000),"
112
+ ],
113
+ field: "readonly upstream: { readonly timeoutMs: number };",
114
+ map: "upstream: { timeoutMs: value.UPSTREAM_TIMEOUT_MS },",
115
+ env: [{ name: "UPSTREAM_TIMEOUT_MS", value: "5000" }]
85
116
  }
86
117
  });
87
118
  var BASE_CONFIG = ["appName", "port", "log"];
@@ -101,13 +132,13 @@ var FEATURES = [
101
132
  summary: "OpenAPI 3.1 from the routes own schemas, plus the Swagger UI page.",
102
133
  requires: [],
103
134
  module: { klass: "DocsModule", from: "./docs/docs.module.js" },
104
- dependencies: ["@dunx/openapi", "swagger-ui-dist", "zod"],
135
+ dependencies: ["@dunx/openapi", "zod"],
105
136
  config: []
106
137
  },
107
138
  {
108
139
  name: "http",
109
140
  source: "http",
110
- summary: "CORS, a request-logging middleware and error mapping.",
141
+ summary: "CORS, a middleware of your own on the response, and error mapping.",
111
142
  requires: [],
112
143
  module: { klass: "HttpModule", from: "./http/http.module.js" },
113
144
  dependencies: [],
@@ -200,11 +231,51 @@ var FEATURES = [
200
231
  {
201
232
  name: "health",
202
233
  source: "health",
203
- summary: "One endpoint reporting which parts are live and which degraded.",
234
+ summary: "`HealthModule`'s liveness and readiness probes, wired to this app's own indicators.",
204
235
  requires: ["cache", "database", "files"],
205
- module: { klass: "HealthModule", from: "./health/health.module.js" },
236
+ module: { klass: "ProbesModule", from: "./health/health.module.js" },
206
237
  dependencies: ["@dunx/infra"],
207
238
  config: ["appName"]
239
+ },
240
+ {
241
+ name: "throttle",
242
+ source: "throttle",
243
+ summary: "A fixed-window rate limit, with the counter in Redis and per-route overrides.",
244
+ requires: ["cache"],
245
+ module: { klass: "LimitsModule", from: "./throttle/throttle.module.js" },
246
+ dependencies: ["@dunx/infra"],
247
+ config: ["appName", "throttle"],
248
+ service: "Redis or Valkey"
249
+ },
250
+ {
251
+ name: "schedule",
252
+ source: "schedule",
253
+ summary: "@Cron, @Interval and @OnceOnBoot on Bun.cron, armed at boot and triggerable.",
254
+ requires: [],
255
+ module: {
256
+ klass: "MaintenanceModule",
257
+ from: "./schedule/schedule.module.js"
258
+ },
259
+ dependencies: ["@dunx/infra"],
260
+ config: ["schedule"]
261
+ },
262
+ {
263
+ name: "assets",
264
+ source: "assets",
265
+ summary: "A static directory on Bun.file, with a short max-age and an immutable rule.",
266
+ requires: [],
267
+ module: { klass: "AssetsModule", from: "./assets/assets.module.js" },
268
+ dependencies: [],
269
+ config: []
270
+ },
271
+ {
272
+ name: "client",
273
+ source: "upstream",
274
+ summary: "The outbound half of @dunx/http: retry, backoff and a typed FetchError.",
275
+ requires: [],
276
+ module: { klass: "UpstreamModule", from: "./upstream/upstream.module.js" },
277
+ dependencies: [],
278
+ config: ["appName", "upstream"]
208
279
  }
209
280
  ];
210
281
  var featureNames = FEATURES.map((feature) => feature.name);
@@ -289,16 +360,15 @@ var manifest = (features) => {
289
360
  dependencies,
290
361
  devDependencies: {
291
362
  "@dunx/testing": "__DUNX_VERSION__",
292
- "@types/bun": ">=1.3.0",
363
+ "@types/bun": ">=1.4.0",
293
364
  typescript: "^5.7.0"
294
365
  },
295
- engines: { bun: ">=1.3.0" }
366
+ engines: { bun: ">=1.4.0" }
296
367
  }, null, 2)}
297
368
  `;
298
369
  };
299
370
  var THIRD_PARTY = Object.freeze({
300
371
  zod: "^4.4.3",
301
- "swagger-ui-dist": "^5.32.14",
302
372
  "drizzle-orm": "^0.45.2",
303
373
  "better-auth": "^1.6.25",
304
374
  bullmq: "^6.0.5",
@@ -307,7 +377,9 @@ var THIRD_PARTY = Object.freeze({
307
377
  var versionOf = (dep) => THIRD_PARTY[dep] ?? "latest";
308
378
  var appModule = (name, features) => {
309
379
  const needsLogger = true;
380
+ const documentsAuth = has(features, "openapi") && has(features, "auth");
310
381
  const imports = [
382
+ ...documentsAuth ? ["import { Auth } from '@dunx/auth';"] : [],
311
383
  "import { ConfigModule, Module } from '@dunx/core';",
312
384
  ...needsLogger ? ["import { LoggerModule } from '@dunx/infra/logger';"] : [],
313
385
  "import { AppConfigService, validate } from './config.js';",
@@ -341,7 +413,10 @@ var appModule = (name, features) => {
341
413
  imports: [
342
414
  ${moduleImports.map((line) => ` ${line}`).join(`
343
415
  `)}
344
- ],
416
+ ],${documentsAuth ? `
417
+ // Better Auth serves its own routes, so the document is the only place they
418
+ // appear - and \`betterAuthDocument\` needs the instance.
419
+ exports: [Auth],` : ""}
345
420
  })
346
421
  export class AppModule {}
347
422
  `;
@@ -406,17 +481,37 @@ var bootstrap = (name, features) => {
406
481
  const openapi = has(features, "openapi");
407
482
  const websockets = has(features, "websockets");
408
483
  const http = has(features, "http");
484
+ const documentsAuth = openapi && has(features, "auth");
485
+ const assets = has(features, "assets");
486
+ const throttle = has(features, "throttle");
409
487
  const imports = [
410
- `import { HttpFactory${websockets ? ", RedisRelay" : ""}, type HttpApp } from '@dunx/http';`,
488
+ ...documentsAuth ? ["import { Auth, betterAuthDocument } from '@dunx/auth';"] : [],
489
+ `import { ${[
490
+ "HttpFactory",
491
+ ...websockets ? ["RedisRelay"] : [],
492
+ ...assets ? ["StaticFiles"] : [],
493
+ ...throttle ? ["ThrottleGuard"] : [],
494
+ "type HttpApp"
495
+ ].join(", ")} } from '@dunx/http';`,
411
496
  ...openapi ? ["import { OpenApiModule } from '@dunx/openapi';"] : [],
412
497
  "import { AppModule } from './app.module.js';",
413
498
  `import { ${[
414
499
  ...http ? ["AppConfigService"] : [],
415
500
  ...websockets ? ["RELAY_CHANNEL"] : []
416
501
  ].join(", ")} } from './config.js';`,
417
- ...http ? ["import { RequestLoggerMiddleware } from './http/request-log.js';"] : []
502
+ ...http ? ["import { RequestTrailMiddleware } from './http/request-trail.js';"] : []
418
503
  ].filter((line) => !line.includes("{ }"));
419
- const root = openapi ? `OpenApiModule.forRoot({
504
+ const root = documentsAuth ? `OpenApiModule.forRootAsync({
505
+ root: AppModule,
506
+ inject: [Auth] as const,
507
+ useFactory: (auth: Auth) => ({
508
+ title: '__DUNX_APP_NAME__',
509
+ version: '0.1.0',
510
+ contribute: [
511
+ betterAuthDocument(auth, { basePath: '/api/auth', tag: 'Auth' }),
512
+ ],
513
+ }),
514
+ })` : openapi ? `OpenApiModule.forRoot({
420
515
  title: '__DUNX_APP_NAME__',
421
516
  version: '0.1.0',
422
517
  root: AppModule,
@@ -431,15 +526,17 @@ var bootstrap = (name, features) => {
431
526
  ] : [];
432
527
  const shaping = [
433
528
  "app.setGlobalPrefix('api');",
529
+ ...assets ? ["app.use(StaticFiles);"] : [],
434
530
  ...http ? [
435
- "app.use(RequestLoggerMiddleware);",
531
+ "app.use(RequestTrailMiddleware);",
436
532
  "app.set('trust proxy', true);",
437
533
  "app.enableCors({",
438
534
  " origin: app.get(AppConfigService).get('corsOrigin'),",
439
535
  " credentials: true,",
440
536
  " maxAge: 600,",
441
537
  "});"
442
- ] : []
538
+ ] : [],
539
+ ...throttle ? ["app.use(ThrottleGuard);"] : []
443
540
  ];
444
541
  return `${HEADER(name)}${imports.join(`
445
542
  `)}
@@ -694,6 +791,3 @@ var scaffold = async (options) => {
694
791
  };
695
792
 
696
793
  export { FEATURES, featureNames, impliedBy, TEMPLATES, VERSION_PLACEHOLDER, ScaffoldError, scaffold };
697
-
698
- //# debugId=BC087157153A09C764756E2164756E21
699
- //# sourceMappingURL=chunk-yzz4z6jv.js.map
package/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  featureNames,
8
8
  impliedBy,
9
9
  scaffold
10
- } from "./chunk-yzz4z6jv.js";
10
+ } from "./chunk-nn9ekg83.js";
11
11
 
12
12
  // src/cli.ts
13
13
  import { parseArgs } from "util";
@@ -129,6 +129,3 @@ try {
129
129
  fail(error.message);
130
130
  throw error;
131
131
  }
132
-
133
- //# debugId=01853E264154F96764756E2164756E21
134
- //# sourceMappingURL=cli.js.map
package/dist/index.js CHANGED
@@ -4,13 +4,10 @@ import {
4
4
  TEMPLATES,
5
5
  VERSION_PLACEHOLDER,
6
6
  scaffold
7
- } from "./chunk-yzz4z6jv.js";
7
+ } from "./chunk-nn9ekg83.js";
8
8
  export {
9
9
  ScaffoldError,
10
10
  TEMPLATES,
11
11
  VERSION_PLACEHOLDER,
12
12
  scaffold
13
13
  };
14
-
15
- //# debugId=2DFAD801C180F0A064756E2164756E21
16
- //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dunx/create-app",
3
- "version": "2.3.1",
3
+ "version": "2.5.0",
4
4
  "description": "Scaffold a new dunx application - bunx @dunx/create-app my-api",
5
5
  "keywords": [
6
6
  "bun",
@@ -58,6 +58,6 @@
58
58
  }
59
59
  },
60
60
  "engines": {
61
- "bun": ">=1.3.0"
61
+ "bun": ">=1.4.0"
62
62
  }
63
63
  }
@@ -0,0 +1,41 @@
1
+ import { Logger } from '@dunx/core';
2
+
3
+ /**
4
+ * The two cache policies, and the traversal refusal.
5
+ *
6
+ * There is no `index.html` fallback and no SPA rewrite here, because `StaticFiles`
7
+ * ships neither: building one in would mean the middleware deciding what a 404
8
+ * means for paths it does not own.
9
+ */
10
+ export class AssetsDemo {
11
+ constructor(private readonly logger: Logger) {}
12
+
13
+ async demonstrate(url: string): Promise<void> {
14
+ for (const path of ['assets/site.css', 'assets/app.a1b2c3d4.js']) {
15
+ const response = await fetch(new URL(path, url));
16
+ this.logger.info(
17
+ `GET /${path} -> ${response.status} ` +
18
+ `${response.headers.get('content-type')}, ` +
19
+ `cache-control: ${response.headers.get('cache-control')}`,
20
+ );
21
+ await response.arrayBuffer();
22
+ }
23
+ this.logger.info(
24
+ 'the hashed name is immutable, the plain one is max-age=60 - a content ' +
25
+ 'hash is the only honest reason to promise forever',
26
+ );
27
+
28
+ // Resolved against the root at construction, and checked on every request.
29
+ const escaped = await fetch(new URL('assets/../../package.json', url));
30
+ this.logger.info(
31
+ `GET /assets/../../package.json -> ${escaped.status} (never leaves the root)`,
32
+ );
33
+
34
+ // Anything outside the mount falls straight through, so the app's own routes
35
+ // and its 404 behave exactly as they did before this was registered.
36
+ const through = await fetch(new URL('api/notes', url));
37
+ this.logger.info(
38
+ `GET /api/notes -> ${through.status} (outside /assets, untouched)`,
39
+ );
40
+ }
41
+ }
@@ -0,0 +1,37 @@
1
+ import { Module } from '@dunx/core';
2
+ import { StaticModule } from '@dunx/http';
3
+ import { AssetsDemo } from './assets.demo.js';
4
+
5
+ /** A content hash, so a change produces a different URL. */
6
+ const HASHED = /\.[0-9a-f]{8}\.(js|css)$/;
7
+
8
+ /**
9
+ * The `public/` directory next to this file, served at `/assets`.
10
+ *
11
+ * `StaticModule` binds `StaticFiles`; the **app** registers it, in `bootstrap.ts`.
12
+ * Position in the chain is the app's decision and no default can make it: assets
13
+ * usually want to be outside an auth guard and inside request logging.
14
+ *
15
+ * The mount is outside `setGlobalPrefix('api')`, because middleware is not a
16
+ * discovered route and never gets the prefix.
17
+ */
18
+ @Module({
19
+ imports: [
20
+ StaticModule.forRoot({
21
+ // Inside the feature folder rather than at the app root, so the folder is
22
+ // self-contained: `@dunx/create-app` vendors this directory wholesale, and
23
+ // an asset kept outside it would need machinery to travel with it.
24
+ root: new URL('./public', import.meta.url).pathname,
25
+ path: '/assets',
26
+ // Short, because a long max-age on a name that can change is a promise the
27
+ // server cannot keep.
28
+ maxAge: 60,
29
+ // Only honest for a content-addressed name. Guessing wrong here is a stale
30
+ // asset nobody can flush, which is why the default claims nothing.
31
+ immutable: (pathname) => HASHED.test(pathname),
32
+ }),
33
+ ],
34
+ providers: [AssetsDemo],
35
+ exports: [AssetsDemo],
36
+ })
37
+ export class AssetsModule {}
@@ -0,0 +1 @@
1
+ globalThis.dunxExample = 'content-addressed, so cacheable forever';
@@ -0,0 +1,9 @@
1
+ <main>
2
+ <h1>dunx static assets</h1>
3
+ <p>
4
+ Served by <code>StaticFiles</code> from <code>examples/full/public</code>,
5
+ on <code>Bun.file</code> - which streams, sets the content type, and answers
6
+ a Range request with sendfile(2).
7
+ </p>
8
+ <script src="/assets/app.a1b2c3d4.js"></script>
9
+ </main>
@@ -0,0 +1,3 @@
1
+ body {
2
+ font-family: system-ui;
3
+ }
@@ -1,8 +1,8 @@
1
- import { AuthModule, bunPassword } from '@dunx/auth';
1
+ import { Auth, AuthModule, bunPassword } from '@dunx/auth';
2
2
  import { drizzleDatabase } from '@dunx/auth/drizzle';
3
3
  import { Module } from '@dunx/core';
4
4
  import { DbConnection } from '@dunx/infra/db';
5
- import { admin, bearer } from 'better-auth/plugins';
5
+ import { admin, bearer, openAPI } from 'better-auth/plugins';
6
6
  import { AppConfigService } from '../config.js';
7
7
  import { DatabaseModule } from '../database/database.module.js';
8
8
  import { AuthDemo } from './auth.demo.js';
@@ -46,7 +46,15 @@ import { ProfileController } from './profile.controller.js';
46
46
  // `admin` puts `role` on the user, which `@Roles()` then reads. `bearer`
47
47
  // lets a non-browser client send `Authorization: Bearer <token>` instead of
48
48
  // a cookie - which is what the tour does.
49
- plugins: [admin(), bearer()],
49
+ // `openAPI()` is what makes `generateOpenAPISchema` exist, and
50
+ // `betterAuthDocument` in bootstrap.ts is what puts its paths in the app's
51
+ // document. `disableDefaultReference` because dunx already serves an
52
+ // explorer at /api/docs and two reference pages is one too many.
53
+ plugins: [
54
+ admin(),
55
+ bearer(),
56
+ openAPI({ disableDefaultReference: true }),
57
+ ],
50
58
  }),
51
59
  inject: [AppConfigService, DbConnection] as const,
52
60
  },
@@ -59,6 +67,9 @@ import { ProfileController } from './profile.controller.js';
59
67
  providers: [AuthTables, Audit, AuthDemo],
60
68
  // `AuthTables` creates them on the app's own handle, so this module needs the
61
69
  // drizzle handle as well as the connection the factory above used.
62
- exports: [Audit, AuthDemo],
70
+ //
71
+ // `Auth` comes back out because `OpenApiModule` wraps the root and can only
72
+ // inject what the root exports - see bootstrap.ts.
73
+ exports: [Audit, AuthDemo, Auth],
63
74
  })
64
75
  export class AccountsModule {}
@@ -12,14 +12,17 @@ import { Sessions } from './sessions.service.js';
12
12
 
13
13
  const SessionKey = z
14
14
  .object({ id: z.string().min(1).max(80) })
15
- .meta({ id: 'SessionKey', title: 'A session id' });
15
+ .meta({ id: 'SessionKey', description: 'A session id' });
16
16
 
17
17
  const StoreSession = z
18
18
  .object({
19
19
  data: z.record(z.string(), z.unknown()),
20
20
  ttl: z.coerce.number().int().min(1).max(3600).default(60),
21
21
  })
22
- .meta({ id: 'StoreSession', title: 'Session payload and its lifetime' });
22
+ .meta({
23
+ id: 'StoreSession',
24
+ description: 'Session payload and its lifetime',
25
+ });
23
26
 
24
27
  const oneSession = { params: SessionKey } as const;
25
28
  const putSession = { params: SessionKey, body: StoreSession } as const;
@@ -14,14 +14,14 @@ import type { Entry } from './schema.js';
14
14
 
15
15
  const EntryIndex = z
16
16
  .object({ id: z.coerce.number().int().min(1) })
17
- .meta({ id: 'EntryIndex', title: 'A ledger entry id in the path' });
17
+ .meta({ id: 'EntryIndex', description: 'A ledger entry id in the path' });
18
18
 
19
19
  const CreateEntry = z
20
20
  .object({
21
21
  memo: z.string().min(1).max(80),
22
22
  amount: z.number().int(),
23
23
  })
24
- .meta({ id: 'CreateEntry', title: 'A single ledger movement' });
24
+ .meta({ id: 'CreateEntry', description: 'A single ledger movement' });
25
25
 
26
26
  /** Both legs succeed or neither does - the rollback is the point of the route. */
27
27
  const Transfer = z
@@ -36,7 +36,7 @@ const Transfer = z
36
36
  */
37
37
  fail: z.boolean().default(false),
38
38
  })
39
- .meta({ id: 'Transfer', title: 'Move an amount between two memos' });
39
+ .meta({ id: 'Transfer', description: 'Move an amount between two memos' });
40
40
 
41
41
  const listEntries = {
42
42
  query: z.object({
@@ -68,7 +68,10 @@ const pageQuery = z
68
68
  .optional()
69
69
  .describe('Opaque cursor from meta.nextCursor. Omit for the first page.'),
70
70
  })
71
- .meta({ id: 'LedgerPageQuery', title: 'Keyset pagination over the ledger' });
71
+ .meta({
72
+ id: 'LedgerPageQuery',
73
+ description: 'Keyset pagination over the ledger',
74
+ });
72
75
 
73
76
  const pagedEntries = { query: pageQuery } as const;
74
77
  const oneEntry = { params: EntryIndex } as const;
@@ -12,12 +12,21 @@ import { z } from 'zod';
12
12
  import { AuthGuard, RolesGuard } from './auth.guard.js';
13
13
  import { ReportsService } from './reports.service.js';
14
14
 
15
+ const CreateReport = z
16
+ .object({ title: z.string().min(1) })
17
+ .meta({ id: 'CreateReport', description: 'A report to file' });
18
+
19
+ const RenameReport = z.object({ title: z.string().min(1) }).meta({
20
+ id: 'RenameReport',
21
+ description: 'A new title for an existing report',
22
+ });
23
+
15
24
  const renameReport = {
16
25
  params: z.object({ id: z.coerce.number().int() }),
17
- body: z.object({ title: z.string().min(1) }),
26
+ body: RenameReport,
18
27
  } as const;
19
28
 
20
- const createReport = { body: z.object({ title: z.string().min(1) }) } as const;
29
+ const createReport = { body: CreateReport } as const;
21
30
 
22
31
  // `@UseGuards(AuthGuard)` at class scope rather than as global middleware: every
23
32
  // other route in this app is meant to be reachable without credentials, and a
@@ -0,0 +1,72 @@
1
+ import { Logger } from '@dunx/core';
2
+ import { Readiness, type HealthReport } from '@dunx/http';
3
+
4
+ /**
5
+ * The two probes an orchestrator reads, and the one thing about them that is worth
6
+ * demonstrating rather than describing: `Readiness.hold()` takes the pod out of
7
+ * rotation while liveness keeps passing, so a migration sheds traffic without
8
+ * inviting a restart.
9
+ */
10
+ export class HealthDemo {
11
+ constructor(
12
+ private readonly logger: Logger,
13
+ private readonly readiness: Readiness,
14
+ ) {}
15
+
16
+ async demonstrate(url: string): Promise<void> {
17
+ const get = async (
18
+ path: string,
19
+ ): Promise<{ status: number; body: HealthReport }> => {
20
+ const response = await fetch(new URL(`api/health/${path}`, url));
21
+ return {
22
+ status: response.status,
23
+ body: (await response.json()) as HealthReport,
24
+ };
25
+ };
26
+
27
+ const live = await get('live');
28
+ this.logger.info(
29
+ `GET /api/health/live -> ${live.status} ${live.body.status}, ` +
30
+ `${this.describe(live.body)}`,
31
+ );
32
+
33
+ const ready = await get('ready');
34
+ this.logger.info(
35
+ `GET /api/health/ready -> ${ready.status} ${ready.body.status}, ` +
36
+ `${this.describe(ready.body)}`,
37
+ );
38
+
39
+ // A non-critical check that is down does not shed traffic, which is the whole
40
+ // reason `critical` exists. With no Redis running, this is that case observed.
41
+ const soft = ready.body.checks.filter(
42
+ (check) => !check.critical && check.state !== 'up',
43
+ );
44
+ this.logger.info(
45
+ soft.length === 0
46
+ ? 'every check up, so critical and non-critical read the same today'
47
+ : `non-critical and down: ${soft.map((c) => c.name).join(', ')} - ` +
48
+ `readiness is still ${ready.body.status}`,
49
+ );
50
+
51
+ // Taking the pod out by hand, the way a migration would.
52
+ this.readiness.hold('migrating');
53
+ const held = await get('ready');
54
+ const heldLive = await get('live');
55
+ this.logger.info(
56
+ `readiness.hold("migrating") -> ready ${held.status} ` +
57
+ `${held.body.checks[0]?.detail ?? ''}, live still ${heldLive.status}`,
58
+ );
59
+
60
+ this.readiness.release();
61
+ this.logger.info(
62
+ `readiness.release() -> ready ${(await get('ready')).status}`,
63
+ );
64
+ }
65
+
66
+ private describe(report: HealthReport): string {
67
+ const checks = report.checks
68
+ .map((check) => `${check.name}=${check.state}`)
69
+ .join(' ');
70
+ return `${report.uptimeMs} ms up, ${checks || 'no checks'}`;
71
+ }
72
+ }
@@ -1,14 +1,75 @@
1
- import { Module } from '@dunx/core';
1
+ import { Module, provide } from '@dunx/core';
2
+ import { HealthModule } from '@dunx/http';
3
+ import { DbConnection } from '@dunx/infra/db';
4
+ import { RedisConnection } from '@dunx/infra/redis';
2
5
  import { CacheModule } from '../cache/cache.module.js';
3
6
  import { DatabaseModule } from '../database/database.module.js';
4
- import { StorageModule } from '../storage/storage.module.js';
5
- import { HealthController } from './health.controller.js';
7
+ import { Ledger } from '../database/ledger.service.js';
8
+ import { WorkspaceModule } from '../storage/storage.module.js';
9
+ import { Workspace } from '../storage/workspace.js';
10
+ import { HealthDemo } from './health.demo.js';
11
+ import { AppIndicators } from './indicators.js';
6
12
 
7
- // A health check is the module that imports the most: it reports on every area, so it
8
- // needs each one's public surface. That is the boundary doing its job - the imports
9
- // list is now an accurate statement of what this feature touches.
13
+ /**
14
+ * The indicators, in a module of their own for the reason `WorkspaceModule` is:
15
+ * `HealthModule.forRootAsync` registers its provider in its own scope, so a factory
16
+ * injecting `AppIndicators` has to name the module it comes from - and pointing that
17
+ * back at `ProbesModule` would be a cycle.
18
+ *
19
+ * A health check is still the feature that imports the most, so this list is an
20
+ * accurate statement of what it touches.
21
+ */
10
22
  @Module({
11
- imports: [DatabaseModule, CacheModule, StorageModule],
12
- controllers: [HealthController],
23
+ imports: [DatabaseModule, CacheModule, WorkspaceModule],
24
+ providers: [
25
+ provide(AppIndicators, {
26
+ // Async because the upload root is: `Workspace.create()` is idempotent, so
27
+ // this is the directory `FilesModule` already made rather than a second one.
28
+ useFactory: async (
29
+ db: DbConnection,
30
+ redis: RedisConnection,
31
+ ledger: Ledger,
32
+ workspace: Workspace,
33
+ ) =>
34
+ new AppIndicators({
35
+ db,
36
+ redis,
37
+ ledger,
38
+ uploadRoot: await workspace.create(),
39
+ }),
40
+ inject: [DbConnection, RedisConnection, Ledger, Workspace] as const,
41
+ }),
42
+ ],
43
+ exports: [AppIndicators],
13
44
  })
14
- export class HealthModule {}
45
+ export class IndicatorsModule {}
46
+
47
+ /**
48
+ * `HealthModule` from `@dunx/http`, which mounts `/api/health/live` and
49
+ * `/api/health/ready`. Both are `@Public()` and hidden from the OpenAPI document:
50
+ * a probe carries no credentials and is not an API a consumer calls.
51
+ *
52
+ * There is no indicator for `@dunx/infra/files` or `@dunx/infra/images`. Both are
53
+ * in-process, so "it booted" is already answered by the port answering at all, and
54
+ * a check that cannot fail tells an operator nothing.
55
+ */
56
+ @Module({
57
+ imports: [
58
+ IndicatorsModule,
59
+ HealthModule.forRootAsync({
60
+ imports: [IndicatorsModule],
61
+ useFactory: (indicators: AppIndicators) => ({
62
+ readiness: indicators.readiness,
63
+ liveness: indicators.liveness,
64
+ // A real deployment sets a few probe intervals here, so a load balancer
65
+ // sees readiness fail before the socket closes. Short enough that
66
+ // `bun run tour` and the suites are not waiting on it.
67
+ drainDelayMs: 250,
68
+ }),
69
+ inject: [AppIndicators] as const,
70
+ }),
71
+ ],
72
+ providers: [HealthDemo],
73
+ exports: [AppIndicators, HealthDemo],
74
+ })
75
+ export class ProbesModule {}