@dunx/create-app 2.5.0 → 3.0.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 (35) hide show
  1. package/dist/features.d.ts +6 -11
  2. package/package.json +1 -1
  3. package/templates/features/assets/assets.module.ts +6 -15
  4. package/templates/features/auth/auth.demo.ts +7 -19
  5. package/templates/features/auth/auth.module.ts +16 -31
  6. package/templates/features/auth/auth.tables.ts +7 -15
  7. package/templates/features/cache/cache.module.ts +7 -13
  8. package/templates/features/chat/chat.demo.ts +10 -21
  9. package/templates/features/chat/chat.gateway.ts +8 -19
  10. package/templates/features/database/database.module.ts +8 -19
  11. package/templates/features/database/ledger.controller.ts +13 -26
  12. package/templates/features/database/ledger.service.ts +16 -44
  13. package/templates/features/docs/docs.demo.ts +13 -32
  14. package/templates/features/health/health.module.ts +10 -19
  15. package/templates/features/health/indicators.ts +10 -26
  16. package/templates/features/http/compression.demo.ts +7 -15
  17. package/templates/features/http/http.demo.ts +7 -13
  18. package/templates/features/http/request-trail.ts +5 -8
  19. package/templates/features/jobs/jobs.controller.ts +7 -18
  20. package/templates/features/jobs/jobs.module.ts +8 -18
  21. package/templates/features/jobs/jobs.processor.ts +5 -13
  22. package/templates/features/schedule/maintenance.service.ts +11 -29
  23. package/templates/features/schedule/schedule.module.ts +3 -7
  24. package/templates/features/storage/files.controller.ts +10 -19
  25. package/templates/features/throttle/limits.controller.ts +5 -12
  26. package/templates/features/throttle/throttle.module.ts +8 -26
  27. package/templates/features/upstream/upstream.demo.ts +6 -15
  28. package/templates/features/upstream/upstream.module.ts +4 -13
  29. package/templates/features/users/users.repository.ts +4 -13
  30. package/templates/features/users/users.schemas.ts +11 -33
  31. package/templates/minimal/src/app.module.ts +0 -5
  32. package/templates/minimal/src/app.test.ts +0 -5
  33. package/templates/minimal/src/greetings.controller.ts +2 -12
  34. package/templates/minimal/src/greetings.service.ts +2 -10
  35. package/templates/minimal/src/main.ts +0 -5
@@ -2,21 +2,13 @@ import { Logger } from '@dunx/core';
2
2
  import { Cron, Interval, OnceOnBoot } from '@dunx/infra/schedule';
3
3
 
4
4
  /**
5
- * The three decorators, on one class.
5
+ * The three schedule decorators on one class, discovered off the prototype chain
6
+ * with no second registration. Nothing here coordinates across replicas: work
7
+ * that must happen once per fleet is a `@JobHandler`.
6
8
  *
7
- * The runner finds them by walking the prototype chains of the classes the modules
8
- * already declare, so none of these needs a second registration - the same
9
- * discovery routes, gateways and `@JobHandler` use.
10
- *
11
- * Nothing here is single-node-unsafe on purpose: two replicas would both run every
12
- * one of these, because nothing in `@dunx/infra/schedule` coordinates. Work that
13
- * must happen once across a fleet is a job, which is `@JobHandler` and bullmq.
14
- */
15
- /**
16
- * Counters are written as `x = x + 1` rather than `x += 1`, and that is not style.
17
- * Bun 1.4.0 refuses to parse a class that has both a decorated member and a
18
- * read-modify-write on a private field - `+=`, `++` and `??=` alike - with a
19
- * `SyntaxError` naming neither. See docs/bun-apis.md.
9
+ * Counters are `x = x + 1` rather than `x += 1` because Bun 1.4.0 fails to parse
10
+ * a class with both a decorated member and a read-modify-write on a private
11
+ * field. See docs/bun-apis.md.
20
12
  */
21
13
  export class Maintenance {
22
14
  #sweeps = 0;
@@ -25,22 +17,15 @@ export class Maintenance {
25
17
 
26
18
  constructor(private readonly logger: Logger) {}
27
19
 
28
- /**
29
- * `0`, so it fires on the next macrotask after the container is ready - and
30
- * "ready" is `onInit`, which is the latest hook there is and runs **before**
31
- * `Bun.serve` binds. Measured: this has already run by the time `listen()`
32
- * resolves, so the first request never sees a cold cache.
33
- */
20
+ /** `0` fires on the next macrotask after `onInit`, which is before
21
+ * `Bun.serve` binds - so the first request never sees a cold cache. */
34
22
  @OnceOnBoot(0, { name: 'maintenance.warm' })
35
23
  warmCaches(): void {
36
24
  this.#warmed = true;
37
25
  this.logger.info('@OnceOnBoot(0): caches warmed, before listen() resolved');
38
26
  }
39
27
 
40
- /**
41
- * Every ten minutes, so it does not fire during a tour or a suite. `trigger`
42
- * runs it now, which is what makes a schedule testable without waiting.
43
- */
28
+ /** Ten minutes, so it never fires during a tour. `trigger` runs it now. */
44
29
  @Interval(600_000, { name: 'maintenance.sweep' })
45
30
  sweepSessions(): number {
46
31
  this.#sweeps = this.#sweeps + 1;
@@ -48,11 +33,8 @@ export class Maintenance {
48
33
  }
49
34
 
50
35
  /**
51
- * Minute resolution: `Bun.cron` rejects a sixth field with "seconds are not
52
- * supported", so anything sub-minute is `@Interval`.
53
- *
54
- * `overlap` is `skip` by default, which is what `Bun.cron` does for free - it
55
- * computes the next fire only once the returned promise settles.
36
+ * Minute resolution: `Bun.cron` rejects a sixth field, so sub-minute work is
37
+ * `@Interval`. `overlap` defaults to `skip`, which `Bun.cron` gives for free.
56
38
  */
57
39
  @Cron('0 3 * * *', { name: 'maintenance.compact' })
58
40
  async compactLedger(): Promise<number> {
@@ -7,13 +7,9 @@ import { ScheduleDemo } from './schedule.demo.js';
7
7
  /**
8
8
  * `Bun.cron` behind `@Cron`, `@Interval` and `@OnceOnBoot`, armed at boot.
9
9
  *
10
- * `keepAlive: false`, unlike the default. `Bun.cron` holds the event loop open so a
11
- * process with an armed schedule and nothing else to do waits for the next fire;
12
- * this app has a server holding it open already, and `bun run tour` has to exit.
13
- *
14
- * `tz` comes from the config because that is the one thing a zero-argument
15
- * `forRoot` cannot reach. A named zone is refused at boot on a Bun that ignores
16
- * `Bun.cron`'s `tz` option, rather than running at the UTC hour and saying nothing.
10
+ * `keepAlive: false`: `Bun.cron` would hold the event loop open, and this app has
11
+ * a server doing that already while `bun run tour` has to exit. `tz` comes from
12
+ * the config, and a named zone is refused at boot on a Bun that ignores it.
17
13
  */
18
14
  @Module({
19
15
  imports: [
@@ -11,10 +11,9 @@ import { LocalStorage, PathTraversalError, Storage } from '@dunx/infra/files';
11
11
  import { z } from 'zod';
12
12
 
13
13
  /**
14
- * The key is a query parameter rather than a path segment because keys contain
15
- * slashes - `reports/q1.csv` is one key, not two segments. Traversal is rejected
16
- * by `Storage` itself rather than by a pattern here, which is the behaviour worth
17
- * seeing: try `?key=../../etc/passwd`.
14
+ * The key is a query parameter because keys contain slashes: `reports/q1.csv` is
15
+ * one key, not two segments. `Storage` itself rejects traversal - try
16
+ * `?key=../../etc/passwd`.
18
17
  */
19
18
  const FileKey = z.object({ key: z.string().min(1).max(200) }).meta({
20
19
  id: 'FileKey',
@@ -34,15 +33,12 @@ const listFiles = {
34
33
  const objectKey = { query: FileKey } as const;
35
34
  const writeFile = { query: FileKey, body: WriteFile } as const;
36
35
 
37
- /**
38
- * Injects `Storage`, never `LocalStorage` - swapping a disk for a bucket is one
39
- * `forRoot` call in storage.module.ts and nothing here changes.
40
- */
36
+ /** Injects `Storage`, never `LocalStorage`: swapping disk for bucket is one
37
+ * `forRoot` call and nothing here changes. */
41
38
  @Controller('files')
42
39
  export class FilesController {
43
40
  constructor(private readonly storage: Storage) {}
44
41
 
45
- /** `list` is an AsyncIterable so a million objects page rather than accumulate. */
46
42
  @Get('/', listFiles)
47
43
  async list(
48
44
  input: Input<typeof listFiles>,
@@ -54,8 +50,7 @@ export class FilesController {
54
50
  })) {
55
51
  keys.push(entry.key);
56
52
  }
57
- // The contract cannot promise a root - narrowing to the backend is how you
58
- // reach anything backend-specific.
53
+ // The contract cannot promise a root, so narrowing reaches it.
59
54
  const root =
60
55
  this.storage instanceof LocalStorage ? this.storage.root : '(remote)';
61
56
  return { root, keys: keys.sort() };
@@ -97,10 +92,8 @@ export class FilesController {
97
92
  return { deleted: true };
98
93
  }
99
94
 
100
- /**
101
- * Nothing signs bytes on a local disk, so this refuses with the backend's own
102
- * message instead of handing back a URL that cannot work.
103
- */
95
+ /** Nothing signs bytes on a local disk, so this refuses rather than hand back
96
+ * a URL that cannot work. */
104
97
  @Get('/presign', objectKey)
105
98
  async presign(input: Input<typeof objectKey>): Promise<{ url: string }> {
106
99
  const { key } = input.query;
@@ -115,10 +108,8 @@ export class FilesController {
115
108
  }
116
109
  }
117
110
 
118
- /**
119
- * A traversal is a bad request, not a server fault - `Storage` rejects it
120
- * before any syscall, and without this it would surface as a 500.
121
- */
111
+ /** A traversal is a bad request: `Storage` rejects it before any syscall, and
112
+ * without this it would surface as a 500. */
122
113
  private async present(key: string): Promise<void> {
123
114
  try {
124
115
  if (await this.storage.exists(key)) return;
@@ -1,33 +1,26 @@
1
1
  import { Controller, Get, SkipThrottle, Throttle } from '@dunx/http';
2
2
 
3
3
  /**
4
- * What the limit looks like from the outside.
5
- *
6
- * The module's default is generous, so the interesting cases are the two
7
- * decorators: `@Throttle` replaces it for one handler, `@SkipThrottle` opts out.
8
- * A limit on the class would cover every handler here, and a handler's own would
9
- * still win - the same precedence `@Roles` has.
4
+ * `@Throttle` replaces the module default for one handler, `@SkipThrottle` opts
5
+ * out. A class-level limit covers every handler, and a handler's own still wins.
10
6
  */
11
7
  @Controller('limits')
12
8
  export class LimitsController {
13
- /**
14
- * Three per minute, per subject. The fourth is a 429 carrying `retry-after`,
15
- * thrown rather than returned, so it comes out in the app's own error shape.
16
- */
9
+ /** Three per minute per subject; the fourth is a thrown 429 with
10
+ * `retry-after`, so it takes the app's own error shape. */
17
11
  @Throttle({ limit: 3, windowSeconds: 60 })
18
12
  @Get('/burst')
19
13
  burst(): { allowed: true } {
20
14
  return { allowed: true };
21
15
  }
22
16
 
23
- /** Exempt. A probe or an internal callback that must never be counted. */
17
+ /** Exempt: a probe or internal callback that must never be counted. */
24
18
  @SkipThrottle()
25
19
  @Get('/exempt')
26
20
  exempt(): { counted: false } {
27
21
  return { counted: false };
28
22
  }
29
23
 
30
- /** The module default, which is what every other route in this app gets. */
31
24
  @Get('/default')
32
25
  standard(): { ok: true } {
33
26
  return { ok: true };
@@ -13,28 +13,12 @@ import { LimitsController } from './limits.controller.js';
13
13
  import { ThrottleDemo } from './throttle.demo.js';
14
14
 
15
15
  /**
16
- * A fixed-window rate limit over the whole app.
16
+ * A fixed-window rate limit over the whole app, picking its counter at boot by
17
+ * asking whether the cache answers. A real deployment names one outright.
17
18
  *
18
- * **Which counter is decided at boot, by asking whether the cache answers.** A real
19
- * deployment names one outright: `RedisThrottleStore` for more than one replica,
20
- * because the in-process default lets each of them allow the full budget. This
21
- * example has to work with nothing installed, so it probes and says which it got.
22
- *
23
- * That probe is `Sessions.status()`, which `CacheModule` already exports rather than
24
- * a second ping written here.
25
- *
26
- * Falling back matters more than it looks. The guard fails **open**, so with
27
- * `RedisThrottleStore` and no Redis nothing is counted at all: no 429, no
28
- * `ratelimit-*` headers, every request through. That is the right call for a
29
- * production limiter, and it would make this example demonstrate nothing on a
30
- * machine without Redis.
31
- *
32
- * `RedisThrottleStore` takes its client structurally, so `RedisConnection` satisfies
33
- * it with no adapter.
34
- *
35
- * The prefix carries the pid, the same trick `Sessions` uses: two runs against one
36
- * Redis would otherwise spend each other's budget, and a leftover window would make
37
- * a suite fail on the previous run's counters.
19
+ * The probe matters because the guard fails open: with `RedisThrottleStore` and
20
+ * no Redis nothing is counted. The prefix carries the pid, so two runs against one
21
+ * Redis cannot spend each other's budget.
38
22
  */
39
23
  @Module({
40
24
  imports: [
@@ -62,11 +46,9 @@ import { ThrottleDemo } from './throttle.demo.js';
62
46
  ? new RedisThrottleStore(redis)
63
47
  : new MemoryThrottleStore(),
64
48
  /**
65
- * Who is being counted. An API key when one is presented, else the
66
- * address - which is the shape a real app wants, where an authenticated
67
- * caller is limited by identity and an anonymous one by where it came
68
- * from. Only a guard ahead of this one knows which, which is why this is
69
- * an option rather than something the package reads for itself.
49
+ * Who is counted: an API key when presented, else the address. Only a
50
+ * guard ahead of this one knows, so it is an option rather than
51
+ * something the package reads for itself.
70
52
  */
71
53
  subject: (req: Bun.BunRequest) =>
72
54
  req.headers.get('x-api-key') ?? address.of(req),
@@ -6,11 +6,9 @@ import {
6
6
  } from '@dunx/http/client';
7
7
 
8
8
  /**
9
- * Calling out, over `fetch` and therefore over no dependency at all.
10
- *
11
- * Three things a bare `fetch` does not do: retry a 503 with backoff, raise a
12
- * non-2xx as an error carrying the parsed body, and forward the inbound request id
13
- * so one trace covers both services.
9
+ * Calling out over `fetch`. Three things a bare `fetch` does not do: retry a 503
10
+ * with backoff, raise a non-2xx as an error carrying the parsed body, and forward
11
+ * the inbound request id.
14
12
  */
15
13
  export class UpstreamDemo {
16
14
  constructor(
@@ -24,7 +22,6 @@ export class UpstreamDemo {
24
22
  );
25
23
  this.logger.info(`GET api/notes -> ${JSON.stringify(notes)}`);
26
24
 
27
- // The 503s are retried; the attempt callback is what makes that visible.
28
25
  const attempts: string[] = [];
29
26
  const recovered = await this.http.get<{ after: number }>(
30
27
  new URL('api/upstream/flaky', url),
@@ -42,12 +39,8 @@ export class UpstreamDemo {
42
39
  `recovered after ${recovered.after}`,
43
40
  );
44
41
 
45
- /**
46
- * A 404 is a `FetchError`, not an `HttpError`. That distinction is the whole
47
- * reason the class exists: an upstream 401 arriving as an `HttpError(401)`
48
- * would make *this* service answer 401, telling its own caller "you are
49
- * unauthorized" when what failed was this service authenticating upstream.
50
- */
42
+ /** A 404 is a `FetchError`, not an `HttpError`: an upstream 401 arriving as
43
+ * `HttpError(401)` would make this service answer 401 to its own caller. */
51
44
  try {
52
45
  await this.http.get(new URL('api/upstream/missing', url), {
53
46
  retry: { maxRetries: 0 },
@@ -61,9 +54,7 @@ export class UpstreamDemo {
61
54
  );
62
55
  }
63
56
 
64
- // Not retried: the budget for the call is spent, and an abort means the
65
- // caller's signal fired or the timeout did. `/slow` sleeps 300 ms, so this
66
- // is a deadline rather than a race with the loopback.
57
+ // Not retried: an abort means the caller's signal or the timeout fired.
67
58
  try {
68
59
  await this.http.get(new URL('api/upstream/slow', url), {
69
60
  timeoutMs: 25,
@@ -5,15 +5,9 @@ import { FlakyController } from './flaky.controller.js';
5
5
  import { UpstreamDemo } from './upstream.demo.js';
6
6
 
7
7
  /**
8
- * The outbound half of `@dunx/http`, from the `./client` subpath.
9
- *
10
- * Imported as `HttpClientModule`, because this app already has an `HttpModule` of
11
- * its own and `@dunx/http` exports `HttpFactory` for the inbound direction. The
12
- * subpath is what keeps the two unambiguous at an import site; the alias is what
13
- * keeps them unambiguous here.
14
- *
15
- * No `baseUrl`: this app calls itself, and its own url is not known until
16
- * `listen()` has run. A real upstream sets one and every call names a path.
8
+ * The outbound half of `@dunx/http`, from the `./client` subpath, aliased because
9
+ * this app has an `HttpModule` of its own. No `baseUrl`: this app calls itself,
10
+ * and its url is not known until `listen()` has run.
17
11
  */
18
12
  @Module({
19
13
  imports: [
@@ -24,12 +18,9 @@ import { UpstreamDemo } from './upstream.demo.js';
24
18
  retry: {
25
19
  maxRetries: 3,
26
20
  retryDelayMs: 20,
27
- // Jitter comes from `crypto.getRandomValues`, not `Math.random`.
28
21
  backoff: { jitterMs: 10, maxMs: 200 },
29
22
  },
30
- // The inbound request id, forwarded to the upstream, so one trace spans
31
- // both services. Read from `RequestContext`, so it only carries when there
32
- // is a request in scope.
23
+ // The inbound request id, forwarded so one trace spans both services.
33
24
  propagateRequestId: true,
34
25
  }),
35
26
  inject: [AppConfigService] as const,
@@ -3,21 +3,13 @@ import { eq, like, sql } from 'drizzle-orm';
3
3
  import * as schema from '../database/schema.js';
4
4
  import { users, type User } from '../database/schema.js';
5
5
 
6
- // The row type comes from the table, so the controller and the service import one
7
- // definition rather than a hand-written copy of the columns.
8
6
  export type { User };
9
7
 
10
8
  export class UsersRepository {
11
9
  /**
12
- * `SyncDatabase` because `DatabaseModule` configured synchronous mode; it is
13
- * drizzle's `BunSQLiteDatabase` with a name the container can tell apart.
14
- * `@dunx/transform` records the bare type name - a real runtime class, so a usable
15
- * token - and ignores the type argument, so the schema types survive injection.
16
- *
17
- * Every method below is `async` although bun-sqlite executes synchronously: the
18
- * HTTP layer awaits them, and moving this table to the pooled backend then costs
19
- * no signature change. `Ledger.transferSync` is what refusing that trade looks
20
- * like.
10
+ * Every method is `async` although bun-sqlite executes synchronously, so moving
11
+ * this table to the pooled backend costs no signature change.
12
+ * `Ledger.transferSync` is what refusing that trade looks like.
21
13
  */
22
14
  constructor(private readonly db: SyncDatabase<typeof schema>) {}
23
15
 
@@ -28,7 +20,7 @@ export class UsersRepository {
28
20
  )`);
29
21
  }
30
22
 
31
- /** One statement for every name; `name` is UNIQUE, so a repeat boot is a no-op. */
23
+ /** `name` is UNIQUE, so a repeat boot is a no-op. */
32
24
  async seed(names: readonly string[]): Promise<void> {
33
25
  this.db
34
26
  .insert(users)
@@ -52,7 +44,6 @@ export class UsersRepository {
52
44
  return this.db.select().from(users).where(eq(users.id, id)).get() ?? null;
53
45
  }
54
46
 
55
- /** `.returning()`, so the id is the one the database wrote. */
56
47
  async create(name: string): Promise<User> {
57
48
  return this.db.insert(users).values({ name }).returning().get();
58
49
  }
@@ -2,28 +2,13 @@ import type { RouteSchemas } from '@dunx/http';
2
2
  import { z } from 'zod';
3
3
 
4
4
  /**
5
- * Real zod, dropped straight into a route's options: `z.object()` already carries
6
- * `~standard` (vendor `zod`, version 1), which is the entire contract
7
- * `@dunx/http` validates against - so nothing adapts anything, and the framework
8
- * still depends on no validator.
5
+ * Plain zod in a route's options: `z.object()` already carries `~standard`, the
6
+ * whole contract `@dunx/http` validates against.
9
7
  *
10
- * `.meta({ id })` names the definition zod emits under `$defs`, which is the slot
11
- * OpenAPI calls `components/schemas`. Without an id the schema is inlined at every
12
- * use site instead of referenced once.
13
- *
14
- * **Prose goes in `description`, not `title`.** In JSON Schema `title` is a short
15
- * display name, and Swagger UI labels a schema by it. A sentence there makes the
16
- * whole Schemas list read as prose: `User` shows up as "A stored user" and is
17
- * impossible to find.
18
- *
19
- * Leaving `title` out is right, and not because it is unused: `@dunx/openapi` fills
20
- * it in with the component name when a schema is hoisted, which is what makes the
21
- * item of `array<User>` read as `User` rather than as `object`.
22
- *
23
- * One more ordering trap: `.strict()` **after** `.meta()` discards the metadata, so
24
- * the schema is inlined despite declaring an id. Put `.meta()` last.
25
- *
26
- * See `UsersDemo` for the generated document.
8
+ * Three traps. `.meta({ id })` names the `$defs` entry, and without it the schema
9
+ * is inlined at every use site. Prose goes in `description` - Swagger UI labels a
10
+ * schema by `title`, and `@dunx/openapi` fills that with the component name.
11
+ * `.strict()` after `.meta()` discards the metadata, so put `.meta()` last.
27
12
  */
28
13
  export const Tag = z
29
14
  .object({ label: z.string().min(1) })
@@ -49,15 +34,9 @@ export const ListUsers = z
49
34
  .meta({ id: 'ListUsers', description: 'Filter and page the user list' });
50
35
 
51
36
  /**
52
- * The response side. Same Standard Schema contract as a request, so it hoists into
53
- * `components/schemas` the same way - and it is **never validated at runtime**:
54
- * the verb decorator holds the handler's return type to it instead, so a route
55
- * declaring this cannot answer with anything else and still compile.
56
- *
57
- * That check is what caught this schema declaring a `tags: string[]` the `users`
58
- * table has no column for. Every user response advertised an array no handler
59
- * returned. `tags` stays on {@link CreateUser}, the request side, where it is
60
- * read - a documented response is a view of the row, and this one is the row.
37
+ * The response side, never validated at runtime: the verb decorator holds the
38
+ * handler's return type to it at compile time instead. That check caught this
39
+ * schema advertising a `tags: string[]` the `users` table has no column for.
61
40
  */
62
41
  export const User = z
63
42
  .object({
@@ -70,8 +49,8 @@ export const NotFound = z
70
49
  .object({ error: z.string(), status: z.literal(404) })
71
50
  .meta({ id: 'NotFound', description: 'Nothing at that id' });
72
51
 
73
- // Declaring a schema is what makes the matching `input` field exist, get parsed
74
- // and get validated. `satisfies` keeps the literal types `Input<>` reads.
52
+ // A declared schema is what makes the matching `input` field exist and be
53
+ // parsed. `satisfies` keeps the literal types `Input<>` reads.
75
54
  export const listUsers = {
76
55
  query: ListUsers,
77
56
  response: { 200: z.array(User) },
@@ -80,7 +59,6 @@ export const oneUser = {
80
59
  params: UserIndex,
81
60
  response: { 200: User, 404: NotFound },
82
61
  } as const satisfies RouteSchemas;
83
- // No status: POST defaults to 201, every other verb to 200.
84
62
  export const createUser = {
85
63
  body: CreateUser,
86
64
  response: { 201: User },
@@ -2,11 +2,6 @@ import { Module } from '@dunx/core';
2
2
  import { GreetingsController } from './greetings.controller.js';
3
3
  import { GreetingsService } from './greetings.service.js';
4
4
 
5
- /**
6
- * The root module. `controllers` are discovered for routes, `providers` are
7
- * everything else. Import order is construction order, and shutdown runs in
8
- * reverse - which matters once there is a database to close.
9
- */
10
5
  @Module({
11
6
  controllers: [GreetingsController],
12
7
  providers: [GreetingsService],
@@ -2,11 +2,6 @@ import { describe, expect, test } from 'bun:test';
2
2
  import { createTestServer } from '@dunx/testing';
3
3
  import { AppModule } from './app.module.js';
4
4
 
5
- /**
6
- * The whole app behind a real `Bun.serve` on port 0. This is also what CI runs to
7
- * prove the example still boots - see `examples/testing` for overrides and the
8
- * rest of `@dunx/testing`.
9
- */
10
5
  describe('minimal', () => {
11
6
  test('serves the greeting', async () => {
12
7
  const server = await createTestServer({ modules: [AppModule] });
@@ -1,13 +1,6 @@
1
1
  import { Controller, Get, type Input, type RouteSchemas } from '@dunx/http';
2
2
  import { GreetingsService } from './greetings.service.js';
3
3
 
4
- /**
5
- * A controller is a provider with routes on it. `GreetingsService` in the
6
- * constructor is resolved the same way the service's own `Logger` was.
7
- *
8
- * Returning a plain object is enough - `@dunx/http` serialises it. There is no
9
- * `Response.json()` to remember and no `res` to forget to send.
10
- */
11
4
  @Controller('greetings')
12
5
  export class GreetingsController {
13
6
  constructor(private readonly greetings: GreetingsService) {}
@@ -17,11 +10,8 @@ export class GreetingsController {
17
10
  return { routes: ['GET /greetings', 'GET /greetings/:name'] };
18
11
  }
19
12
 
20
- /**
21
- * No schemas are declared, so a path param stays on `input.req.params` as a
22
- * string. Declaring a `params` schema is what makes it typed and coerced -
23
- * `examples/full` does that; this one is showing the shape, not validation.
24
- */
13
+ // With no `params` schema declared, a path param stays a string.
14
+ // `examples/full` shows the typed, coerced version.
25
15
  @Get('/:name')
26
16
  one(input: Input<RouteSchemas>): { greeting: string; served: number } {
27
17
  return this.greetings.greet(input.req.params['name'] ?? 'world');
@@ -1,20 +1,12 @@
1
1
  import { Logger, type OnInit } from '@dunx/core';
2
2
 
3
- /**
4
- * A provider. No decorator, no registration boilerplate - being listed in a
5
- * module's `providers` is what makes it injectable.
6
- *
7
- * `Logger` in the constructor is the whole dependency injection story: the
8
- * container reads the parameter's type and resolves it. Nothing bound `Logger`
9
- * here, so it gets core's default `ConsoleLogger`, which writes one JSON line
10
- * per entry and needs no dependency.
11
- */
3
+ /** A provider is a plain class. Listing it in a module's `providers` is what
4
+ * makes it injectable; the container reads `Logger` off the constructor. */
12
5
  export class GreetingsService implements OnInit {
13
6
  #greeted = 0;
14
7
 
15
8
  constructor(private readonly logger: Logger) {}
16
9
 
17
- /** Runs after the whole graph is constructed, in dependency order. */
18
10
  onInit(): void {
19
11
  this.logger.info('greetings ready');
20
12
  }
@@ -1,11 +1,6 @@
1
1
  import { HttpFactory } from '@dunx/http';
2
2
  import { AppModule } from './app.module.js';
3
3
 
4
- /**
5
- * `create()` builds the container and discovers routes; `listen()` builds the
6
- * `Bun.serve` route table and returns the URL. `enableShutdownHooks()` makes
7
- * `ctrl-c` drain the graph in reverse construction order.
8
- */
9
4
  const app = await HttpFactory.create(AppModule);
10
5
  app.enableShutdownHooks();
11
6