@dunx/create-app 2.4.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 (45) hide show
  1. package/dist/{chunk-mpa5nv1v.js → chunk-nn9ekg83.js} +2 -5
  2. package/dist/cli.js +1 -4
  3. package/dist/features.d.ts +6 -11
  4. package/dist/index.js +1 -4
  5. package/package.json +2 -2
  6. package/templates/features/assets/assets.module.ts +6 -15
  7. package/templates/features/auth/auth.demo.ts +7 -19
  8. package/templates/features/auth/auth.module.ts +16 -31
  9. package/templates/features/auth/auth.tables.ts +7 -15
  10. package/templates/features/cache/cache.module.ts +7 -13
  11. package/templates/features/chat/chat.demo.ts +10 -21
  12. package/templates/features/chat/chat.gateway.ts +8 -19
  13. package/templates/features/database/database.module.ts +8 -19
  14. package/templates/features/database/ledger.controller.ts +13 -26
  15. package/templates/features/database/ledger.service.ts +16 -44
  16. package/templates/features/docs/docs.demo.ts +13 -32
  17. package/templates/features/health/health.module.ts +10 -19
  18. package/templates/features/health/indicators.ts +10 -26
  19. package/templates/features/http/compression.demo.ts +80 -0
  20. package/templates/features/http/http.demo.ts +7 -13
  21. package/templates/features/http/http.module.ts +30 -2
  22. package/templates/features/http/request-trail.ts +5 -8
  23. package/templates/features/http/trace.controller.ts +30 -0
  24. package/templates/features/http/trace.demo.ts +58 -0
  25. package/templates/features/jobs/jobs.controller.ts +7 -18
  26. package/templates/features/jobs/jobs.module.ts +8 -18
  27. package/templates/features/jobs/jobs.processor.ts +5 -13
  28. package/templates/features/schedule/maintenance.service.ts +11 -29
  29. package/templates/features/schedule/schedule.module.ts +3 -7
  30. package/templates/features/storage/files.controller.ts +10 -19
  31. package/templates/features/throttle/limits.controller.ts +5 -12
  32. package/templates/features/throttle/throttle.module.ts +8 -26
  33. package/templates/features/upstream/upstream.demo.ts +6 -15
  34. package/templates/features/upstream/upstream.module.ts +4 -13
  35. package/templates/features/users/users.controller.ts +8 -0
  36. package/templates/features/users/users.repository.ts +4 -13
  37. package/templates/features/users/users.schemas.ts +11 -28
  38. package/templates/minimal/src/app.module.ts +0 -5
  39. package/templates/minimal/src/app.test.ts +0 -5
  40. package/templates/minimal/src/greetings.controller.ts +2 -12
  41. package/templates/minimal/src/greetings.service.ts +2 -10
  42. package/templates/minimal/src/main.ts +0 -5
  43. package/dist/chunk-mpa5nv1v.js.map +0 -12
  44. package/dist/cli.js.map +0 -10
  45. package/dist/index.js.map +0 -9
@@ -17,8 +17,7 @@ const Enqueue = z
17
17
  width: z.coerce.number().int().min(1).max(1024).default(128),
18
18
  format: z.enum(EncodableFormat).default(EncodableFormat.WEBP),
19
19
  })
20
- // `.strict()` **after** `.meta()` discards the metadata - `meta()` then returns
21
- // null and the schema is inlined despite declaring an id. Order matters.
20
+ // `.strict()` after `.meta()` discards the metadata; put `.meta()` last.
22
21
  .strict()
23
22
  .meta({
24
23
  id: 'EnqueueRender',
@@ -28,11 +27,7 @@ const Enqueue = z
28
27
  const enqueue = { body: Enqueue } as const;
29
28
  const oneJob = { params: z.object({ id: z.string().min(1) }) } as const;
30
29
 
31
- /**
32
- * The publish side. Nothing here consumes: `QueueModule.forRoot` binds
33
- * `JobPublisher` and no worker, so this process enqueues and returns immediately.
34
- * Consumed by this same process - see `JobsModule`'s `consume: true`.
35
- */
30
+ /** The publish side: `QueueModule.forRoot` binds `JobPublisher` and no worker. */
36
31
  @Controller('jobs')
37
32
  export class JobsController {
38
33
  constructor(private readonly publisher: JobPublisher) {}
@@ -48,16 +43,12 @@ export class JobsController {
48
43
  return {
49
44
  id: job.id ?? '(unassigned)',
50
45
  queue: THUMBNAIL_QUEUE,
51
- // `waiting` until a worker takes it - which is the observable point of a
52
- // queue, so it is in the response rather than hidden.
53
46
  state: await job.getState(),
54
47
  };
55
48
  }
56
49
 
57
- /**
58
- * Poll a job. `returnvalue` is whatever the handler returned, so this is how the
59
- * web process reads a result computed in another process.
60
- */
50
+ /** `returnvalue` is whatever the handler returned, so this is how the web
51
+ * process reads a result computed elsewhere. */
61
52
  @Get('/thumbnails/:id', oneJob)
62
53
  async status(input: Input<typeof oneJob>): Promise<{
63
54
  id: string;
@@ -84,11 +75,9 @@ export class JobsController {
84
75
  }
85
76
 
86
77
  /**
87
- * No Redis is a degraded queue, not a broken app - the same contract the cache
88
- * routes keep. bullmq surfaces the failure through ioredis rather than Bun's
89
- * client, so the connection-error shape is not guaranteed to match; anything
90
- * unrecognised still becomes a 503 rather than a 500, because "the queue is not
91
- * reachable" is the only thing it can mean here.
78
+ * No Redis is a degraded queue, not a broken app. bullmq surfaces the failure
79
+ * through ioredis, so the error shape is not guaranteed; anything unrecognised
80
+ * still becomes a 503.
92
81
  */
93
82
  private async degrades<T>(run: () => Promise<T>): Promise<T> {
94
83
  try {
@@ -6,17 +6,9 @@ import { JobsController } from './jobs.controller.js';
6
6
  import { ThumbnailJobs } from './thumbnail.jobs.js';
7
7
 
8
8
  /**
9
- * Imported by **both** containers, which is the whole shape of a queue: the web
10
- * process publishes, a separate worker process consumes, and they agree only on
11
- * this module.
12
- *
13
- * `consume: true` is what makes this process work them as well as publish, and it
14
- * is the only line about it anywhere - the container owns starting and stopping the
15
- * workers, so no entrypoint has to. Leave it out and the module binds the publish
16
- * side alone, which is what a web tier with a separate worker fleet wants.
17
- *
18
- * `PicturesModule` is here because the handler injects `Thumbnails`, and the
19
- * container that runs it has to be able to build it.
9
+ * Imported by both containers: the web process publishes, a worker process
10
+ * consumes, and they agree only on this module. `consume: true` makes this
11
+ * process work them too; leave it out and it binds the publish side alone.
20
12
  */
21
13
  @Module({
22
14
  imports: [
@@ -26,12 +18,11 @@ import { ThumbnailJobs } from './thumbnail.jobs.js';
26
18
  return {
27
19
  ...(url === undefined ? {} : { url }),
28
20
  prefix: 'dunx-full',
29
- // This process works its own queues. The container starts the workers at
30
- // onInit and stops them at onShutdown - before the database they use -
31
- // so `main.ts` says nothing about queues and there is no second command.
21
+ // The container starts the workers at onInit and stops them before
22
+ // the database they use, so `main.ts` says nothing about queues.
32
23
  consume: true,
33
- // The file bullmq forks into for a queue whose handler is marked
34
- // `background`. Absolute, because the child resolves it, not this module.
24
+ // Where bullmq forks for a `background` handler. Absolute: the child
25
+ // resolves it, not this module.
35
26
  processor: new URL('./jobs.processor.ts', import.meta.url).pathname,
36
27
  };
37
28
  },
@@ -41,8 +32,7 @@ import { ThumbnailJobs } from './thumbnail.jobs.js';
41
32
  ],
42
33
  controllers: [JobsController],
43
34
  providers: [ThumbnailJobs],
44
- // The publisher, re-exported so a feature that enqueues does not import
45
- // @dunx/infra/queue itself.
35
+ // Re-exported so a feature that enqueues does not import @dunx/infra/queue.
46
36
  exports: [JobPublisher, ThumbnailJobs],
47
37
  })
48
38
  export class JobsModule {}
@@ -5,25 +5,17 @@ import { AppConfigService, validate } from '../config.js';
5
5
  import { JobsModule } from './jobs.module.js';
6
6
 
7
7
  /**
8
- * **The file bullmq forks into.** Its default export is the processor, and nothing
9
- * else here runs in the parent.
10
- *
11
- * The child builds its own container, which is the whole point: a handler gets the
12
- * database, the image pipeline and the logger it declares, without sharing an event
13
- * loop with the process serving HTTP. `JobProcessor` builds it once per child and
14
- * reuses it for every job on that child.
15
- *
16
- * Its own module rather than reusing `WorkerModule` from `worker.ts`: that file is
17
- * an entrypoint with a `run()` at the bottom, and importing it here would boot a
18
- * second worker inside every child.
8
+ * The file bullmq forks into; its default export is the processor. The child
9
+ * builds its own container, so a handler gets what it declares without sharing
10
+ * an event loop with the HTTP process. Its own module rather than `worker.ts`,
11
+ * which has a `run()` that would boot a second worker inside every child.
19
12
  */
20
13
  @Module({
21
14
  imports: [
22
15
  ConfigModule.forRoot({ validate, as: AppConfigService }),
23
16
  LoggerModule.forRootAsync({
24
17
  useFactory: (config: AppConfigService) => ({
25
- // Named so a line from a child is attributable to one on sight - which is
26
- // the traceability a sandbox is for.
18
+ // Named, so a line from a child is attributable on sight.
27
19
  name: `${config.get('appName')}-job`,
28
20
  level: config.get('log').level,
29
21
  }),
@@ -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,
@@ -17,11 +17,19 @@ export class UsersController {
17
17
  // `Input<typeof listUsers>` has to be written out - a standard method decorator
18
18
  // can check a parameter's type but cannot contextually type an unannotated one.
19
19
  // Every field type still comes from the schema, so nothing is declared twice.
20
+ //
21
+ // The return type is checked too, against `listUsers.response[200]`: a handler
22
+ // that answers with a shape the document does not describe is a TS1241 here
23
+ // rather than a surprise for whoever read the document. `readonly User[]` is
24
+ // accepted against a schema inferring `User[]` - mutability does not survive
25
+ // serialisation.
20
26
  @Get('/', listUsers)
21
27
  list(input: Input<typeof listUsers>): Promise<readonly User[]> {
22
28
  return this.users.findAll(input.query.limit, input.query.q);
23
29
  }
24
30
 
31
+ // Only the success status is checked - the 404 in `oneUser.response` leaves via
32
+ // a thrown HttpError, which no return type can describe.
25
33
  @Get('/:id', oneUser)
26
34
  async one(input: Input<typeof oneUser>): Promise<User> {
27
35
  // Already a number: the params schema coerced it before this ran.
@@ -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,14 @@ 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 - but it is **never validated**: it documents
54
- * what comes back, and the handler's return type is what checks it.
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.
55
40
  */
56
41
  export const User = z
57
42
  .object({
58
43
  id: z.number().int(),
59
44
  name: z.string(),
60
- tags: z.array(z.string()),
61
45
  })
62
46
  .meta({ id: 'User', description: 'A stored user' });
63
47
 
@@ -65,8 +49,8 @@ export const NotFound = z
65
49
  .object({ error: z.string(), status: z.literal(404) })
66
50
  .meta({ id: 'NotFound', description: 'Nothing at that id' });
67
51
 
68
- // Declaring a schema is what makes the matching `input` field exist, get parsed
69
- // 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.
70
54
  export const listUsers = {
71
55
  query: ListUsers,
72
56
  response: { 200: z.array(User) },
@@ -75,7 +59,6 @@ export const oneUser = {
75
59
  params: UserIndex,
76
60
  response: { 200: User, 404: NotFound },
77
61
  } as const satisfies RouteSchemas;
78
- // No status: POST defaults to 201, every other verb to 200.
79
62
  export const createUser = {
80
63
  body: CreateUser,
81
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] });