@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
@@ -360,10 +360,10 @@ var manifest = (features) => {
360
360
  dependencies,
361
361
  devDependencies: {
362
362
  "@dunx/testing": "__DUNX_VERSION__",
363
- "@types/bun": ">=1.3.0",
363
+ "@types/bun": ">=1.4.0",
364
364
  typescript: "^5.7.0"
365
365
  },
366
- engines: { bun: ">=1.3.0" }
366
+ engines: { bun: ">=1.4.0" }
367
367
  }, null, 2)}
368
368
  `;
369
369
  };
@@ -791,6 +791,3 @@ var scaffold = async (options) => {
791
791
  };
792
792
 
793
793
  export { FEATURES, featureNames, impliedBy, TEMPLATES, VERSION_PLACEHOLDER, ScaffoldError, scaffold };
794
-
795
- //# debugId=8590CB93017DD8C964756E2164756E21
796
- //# sourceMappingURL=chunk-mpa5nv1v.js.map
package/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  featureNames,
8
8
  impliedBy,
9
9
  scaffold
10
- } from "./chunk-mpa5nv1v.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
@@ -1,16 +1,11 @@
1
1
  /**
2
- * The features a generated app can be composed from, each one a directory of
3
- * `examples/full` - the example CI boots and tours on every push.
2
+ * The features a generated app can be composed from, each a directory of
3
+ * `examples/full`, which CI boots and tours on every push - a template nobody runs
4
+ * rots. `bun run sync:templates` copies them in and `features.test.ts` fails on
5
+ * drift.
4
6
  *
5
- * That is the whole point of sourcing them there rather than writing starter code
6
- * here: a template nobody runs rots, and this repo already runs `examples/full`
7
- * end to end. `bun run sync:templates` copies the directories in and
8
- * `features.test.ts` fails if a copy drifts, so what gets scaffolded is what CI
9
- * proved works.
10
- *
11
- * What is **not** copied is the wiring: `app.module.ts`, `config.ts`,
12
- * `bootstrap.ts` and `main.ts` in the full example name every feature at once, so
13
- * they are generated from the selection instead. See `generate.ts`.
7
+ * The wiring is not copied: `app.module.ts`, `config.ts`, `bootstrap.ts` and
8
+ * `main.ts` name every feature at once, so they are generated from the selection.
14
9
  */
15
10
  export interface Feature {
16
11
  /** Flag name, and the directory under `templates/features/`. */
package/dist/index.js CHANGED
@@ -4,13 +4,10 @@ import {
4
4
  TEMPLATES,
5
5
  VERSION_PLACEHOLDER,
6
6
  scaffold
7
- } from "./chunk-mpa5nv1v.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.4.0",
3
+ "version": "3.0.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
  }
@@ -6,28 +6,19 @@ import { AssetsDemo } from './assets.demo.js';
6
6
  const HASHED = /\.[0-9a-f]{8}\.(js|css)$/;
7
7
 
8
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.
9
+ * `public/` served at `/assets`. `StaticModule` binds `StaticFiles`; the app
10
+ * registers it in `bootstrap.ts`, because position in the chain is the app's
11
+ * call. The mount skips the global prefix: middleware is not a discovered route.
17
12
  */
18
13
  @Module({
19
14
  imports: [
20
15
  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.
16
+ // Inside the feature folder, which `@dunx/create-app` vendors wholesale.
24
17
  root: new URL('./public', import.meta.url).pathname,
25
18
  path: '/assets',
26
- // Short, because a long max-age on a name that can change is a promise the
27
- // server cannot keep.
28
19
  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.
20
+ // Only honest for a content-addressed name: guessing wrong leaves a stale
21
+ // asset nobody can flush.
31
22
  immutable: (pathname) => HASHED.test(pathname),
32
23
  }),
33
24
  ],
@@ -11,11 +11,8 @@ const CREDENTIALS = {
11
11
  name: 'Ada',
12
12
  };
13
13
 
14
- /**
15
- * The whole loop over HTTP: better-auth's own mounted endpoints sign a user up and
16
- * in, then dunx's `SessionGuard` decides who reaches `/api/profile`. Nothing here
17
- * reimplements an auth flow - every `/api/auth/*` call lands in better-auth.
18
- */
14
+ /** The whole loop over HTTP: better-auth's mounted endpoints sign a user up and
15
+ * in, then `SessionGuard` decides who reaches `/api/profile`. */
19
16
  export class AuthDemo {
20
17
  constructor(
21
18
  private readonly logger: Logger,
@@ -28,8 +25,6 @@ export class AuthDemo {
28
25
 
29
26
  async demonstrate(url: string): Promise<void> {
30
27
  const base = new URL(url).origin;
31
- // `$context` is where the resolved configuration lands; `options.baseURL` is
32
- // whatever was passed in, which better-auth also allows to be a function.
33
28
  this.#origin = (await this.auth.$context).baseURL;
34
29
  this.logger.info(
35
30
  `better-auth ${this.auth.options.basePath} mounted, hashing with Bun.password bcrypt`,
@@ -46,8 +41,7 @@ export class AuthDemo {
46
41
  });
47
42
  await this.report('POST /api/auth/sign-in/email', signIn);
48
43
 
49
- // The `bearer` plugin returns the session token in a header, so a server-side
50
- // client authenticates without a cookie jar.
44
+ // The `bearer` plugin returns the token in a header, so no cookie jar.
51
45
  const token = signIn.headers.get('set-auth-token') ?? '';
52
46
  const cookie = signIn.headers
53
47
  .getSetCookie()
@@ -81,8 +75,7 @@ export class AuthDemo {
81
75
  },
82
76
  );
83
77
 
84
- // What an admin console would do. The `admin` plugin's own `setRole` endpoint
85
- // needs an existing admin to call it, and there is none yet.
78
+ // The `admin` plugin's `setRole` needs an existing admin, and there is none.
86
79
  this.db
87
80
  .update(user)
88
81
  .set({ role: 'admin' })
@@ -122,8 +115,7 @@ export class AuthDemo {
122
115
  },
123
116
  );
124
117
 
125
- // The instance is injectable, so a service can ask better-auth directly rather
126
- // than going over HTTP.
118
+ // Injectable, so a service can ask better-auth without going over HTTP.
127
119
  const session = await this.auth.api.getSession({
128
120
  headers: new Headers({ cookie }),
129
121
  });
@@ -132,12 +124,8 @@ export class AuthDemo {
132
124
  );
133
125
  }
134
126
 
135
- /**
136
- * `Origin` is set because better-auth rejects a cookie-bearing state change without
137
- * one - `MISSING_OR_NULL_ORIGIN`, its CSRF check. A browser sends it for free; a
138
- * server-side client has to, and the value that has to match is `trustedOrigins`,
139
- * which defaults to the configured `baseURL`.
140
- */
127
+ /** better-auth rejects a cookie-bearing state change with no `Origin`
128
+ * (`MISSING_OR_NULL_ORIGIN`); it has to match `trustedOrigins`. */
141
129
  private post(
142
130
  base: string,
143
131
  endpoint: string,
@@ -10,46 +10,36 @@ import { AuthTables } from './auth.tables.js';
10
10
  import { Audit } from './audit.service.js';
11
11
  import { ProfileController } from './profile.controller.js';
12
12
 
13
- /** Named for the feature rather than the package, so `AuthModule` still means `@dunx/auth`'s. */
13
+ /** Named for the feature, so `AuthModule` still means `@dunx/auth`'s. */
14
14
  @Module({
15
15
  imports: [
16
- // The drizzle handle, for `AuthTables` and `Audit`.
17
16
  DatabaseModule,
18
- // `forRootAsync` because the secret and the base URL come from the validated
19
- // config, and the database from the connection `DatabaseModule` already opened -
20
- // none of which a zero-argument factory could reach.
17
+ // `forRootAsync`: the secret, base URL and connection all come from the
18
+ // container, which a zero-argument factory cannot reach.
21
19
  AuthModule.forRootAsync(
22
20
  {
23
- // `DbConnection` comes from DatabaseModule, and the provider this factory
24
- // configures lives in AuthModule's scope - so the module it comes from has to
25
- // be named. `AppConfigService` needs no naming: ConfigModule is global.
21
+ // `DbConnection` is in DatabaseModule's scope, so it has to be named.
22
+ // `AppConfigService` does not: ConfigModule is global.
26
23
  imports: [DatabaseModule],
27
24
  useFactory: (config: AppConfigService, connection: DbConnection) => ({
28
25
  secret: config.get('auth').secret,
29
26
  baseURL: `http://localhost:${config.get('port')}`,
30
- // What better-auth matches an incoming pathname against. `app.setGlobalPrefix('api')`
31
- // is what makes the mounted `/auth` route answer here.
27
+ // What better-auth matches a pathname against; the global prefix is
28
+ // what makes the mounted `/auth` route answer here.
32
29
  basePath: '/api/auth',
33
- // The app's one drizzle handle. No second pool, no second SQLite file, and
34
- // the connection still closes exactly once, last.
35
30
  database: drizzleDatabase(connection),
36
- // `password: bunPassword` is what `AuthModule` would apply anyway when
37
- // `emailAndPassword` is on and no hasher is given - named here so it is
38
- // visible. better-auth's own default is a pure-JavaScript scrypt;
39
- // `bunPassword` is `Bun.password`'s native bcrypt, which is Rule 1's
40
- // first half. Bun pre-hashes, so bcrypt's 72-byte cap is a non-issue.
31
+ // The default `AuthModule` would apply anyway, named here to be
32
+ // visible. better-auth's own default is JavaScript scrypt; this is
33
+ // `Bun.password`'s native bcrypt.
41
34
  emailAndPassword: {
42
35
  enabled: true,
43
36
  minPasswordLength: 8,
44
37
  password: bunPassword,
45
38
  },
46
- // `admin` puts `role` on the user, which `@Roles()` then reads. `bearer`
47
- // lets a non-browser client send `Authorization: Bearer <token>` instead of
48
- // a cookie - which is what the tour does.
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.
39
+ // `admin` puts `role` on the user for `@Roles()`; `bearer` lets the
40
+ // tour send a token instead of a cookie. `openAPI()` is what makes
41
+ // `generateOpenAPISchema` exist for `betterAuthDocument` to merge, and
42
+ // `disableDefaultReference` keeps it to one explorer.
53
43
  plugins: [
54
44
  admin(),
55
45
  bearer(),
@@ -58,18 +48,13 @@ import { ProfileController } from './profile.controller.js';
58
48
  }),
59
49
  inject: [AppConfigService, DbConnection] as const,
60
50
  },
61
- // The route path. The global prefix turns it into `/api/auth`, the `basePath`
62
- // above - see AuthOptions.mountAt.
63
51
  '/auth',
64
52
  ),
65
53
  ],
66
54
  controllers: [ProfileController],
67
55
  providers: [AuthTables, Audit, AuthDemo],
68
- // `AuthTables` creates them on the app's own handle, so this module needs the
69
- // drizzle handle as well as the connection the factory above used.
70
- //
71
- // `Auth` comes back out because `OpenApiModule` wraps the root and can only
72
- // inject what the root exports - see bootstrap.ts.
56
+ // `Auth` is exported because `OpenApiModule` wraps the root and can only
57
+ // inject what the root exports. See bootstrap.ts.
73
58
  exports: [Audit, AuthDemo, Auth],
74
59
  })
75
60
  export class AccountsModule {}
@@ -4,17 +4,12 @@ import { sql } from 'drizzle-orm';
4
4
  import * as schema from '../database/schema.js';
5
5
 
6
6
  /**
7
- * better-auth's tables, created at `onInit` for the same reason `Ledger` creates its
8
- * own: a `:memory:` database has nowhere to keep a migration journal. A real app runs
9
- * `bunx @better-auth/cli generate` and then `drizzle-kit`, which own the SQL.
7
+ * better-auth's tables, created at `onInit` because a `:memory:` database has
8
+ * nowhere to keep a migration journal. A real app runs
9
+ * `bunx @better-auth/cli generate` and then `drizzle-kit`.
10
10
  *
11
- * The column names are drizzle's defaults for the schema in `database/auth.schema.ts`
12
- * - camelCase, because that file passes no explicit names.
13
- */
14
- /**
15
- * One statement per entry, not one template with four. `db.run` goes through
16
- * `bun:sqlite`'s `prepare`, which compiles a single statement and silently drops
17
- * whatever follows the first semicolon - the table after it simply never exists.
11
+ * One statement per entry: `db.run` goes through `bun:sqlite`'s `prepare`, which
12
+ * compiles one statement and silently drops whatever follows the first semicolon.
18
13
  */
19
14
  const TABLES = [
20
15
  sql`CREATE TABLE IF NOT EXISTS user (
@@ -72,11 +67,8 @@ export class AuthTables implements OnInit {
72
67
  private readonly logger: Logger,
73
68
  ) {}
74
69
 
75
- /**
76
- * `onInit`, not the module factory: `betterAuth()` opens no connection and issues
77
- * no query when it is built, so the tables only have to exist before the first
78
- * request - and this runs before `listen()` binds.
79
- */
70
+ /** `onInit` rather than the module factory: `betterAuth()` queries nothing when
71
+ * built, and this still runs before `listen()` binds. */
80
72
  onInit(): void {
81
73
  for (const table of TABLES) this.db.run(table);
82
74
  this.logger.info(`better-auth tables created (${TABLES.length})`);
@@ -6,20 +6,15 @@ import { Sessions } from './sessions.service.js';
6
6
 
7
7
  @Module({
8
8
  imports: [
9
- // Without a url Bun's own chain decides it - $VALKEY_URL, then $REDIS_URL,
10
- // then valkey://localhost:6379. Connections are lazy, so nothing is dialled
11
- // here and an unavailable cache cannot stop the process from booting.
12
- // `eager: true` would opt into finding out at startup, which is the opposite
13
- // of the point: the cache routes report themselves degraded instead.
9
+ // No url, so Bun resolves $VALKEY_URL, $REDIS_URL, then localhost.
10
+ // Connections are lazy, so an unavailable cache cannot stop boot.
14
11
  //
15
- // `maxRetries: 0` is not just impatience: measured on Bun 1.3.14, a client
16
- // that failed to connect with `maxRetries > 0` keeps a retry timer alive even
17
- // after `close()`, and the process never exits. With 0 it exits cleanly.
12
+ // `maxRetries: 0` because on Bun 1.3.14 a client that failed to connect with
13
+ // `maxRetries > 0` keeps a retry timer alive after `close()` and never exits.
18
14
  RedisModule.forRootAsync({
19
15
  useFactory: (config: AppConfigService) => {
20
- // Destructured first: `exactOptionalPropertyTypes` will not let a
21
- // `string | undefined` reach a `url?: string`, even inside the branch
22
- // that has already ruled `undefined` out.
16
+ // `exactOptionalPropertyTypes` will not let `string | undefined` reach
17
+ // a `url?: string`, even where `undefined` is ruled out.
23
18
  const { url } = config.get('redis');
24
19
  return {
25
20
  ...(url === undefined ? {} : { url }),
@@ -32,8 +27,7 @@ import { Sessions } from './sessions.service.js';
32
27
  ],
33
28
  controllers: [CacheController],
34
29
  providers: [Sessions],
35
- // Re-exported: the chat gateway fans out across processes through the same
36
- // connection, so it imports this module rather than opening a second client.
30
+ // Re-exported so the chat gateway fans out through the same connection.
37
31
  exports: [RedisConnection, Sessions],
38
32
  })
39
33
  export class CacheModule {}
@@ -58,12 +58,9 @@ const connect = async (base: string): Promise<Client> => {
58
58
  };
59
59
 
60
60
  /**
61
- * A second node, in-process. Two `Bun.serve` instances, two containers, two
62
- * `PubSub`s with two different origin ids - everything a second deployment has
63
- * except a second pid, which the relay logic cannot tell apart anyway.
64
- *
65
- * It reuses the very same `ChatGateway`, and takes only what that gateway needs:
66
- * `ChatDemo` itself is not in here, so this module cannot recurse.
61
+ * A second node in-process: two `Bun.serve` instances, two containers, two
62
+ * `PubSub` origin ids. It reuses the same `ChatGateway` and excludes `ChatDemo`,
63
+ * so it cannot recurse.
67
64
  */
68
65
  @Module({ providers: [ChatGateway, Lobby] })
69
66
  class PeerNode {}
@@ -107,20 +104,13 @@ export class ChatDemo {
107
104
 
108
105
  ada.close();
109
106
  grace.close();
110
- // Long enough for @OnClose to run before the tour moves on.
111
107
  await Bun.sleep(20);
112
108
  }
113
109
 
114
110
  /**
115
- * The relay: a publish on this node reaching a client connected to a *different*
116
- * node, exactly once. Both nodes run in this process - two `Bun.serve`
117
- * instances, two containers - which is every part of a two-machine deployment
118
- * that the fan-out logic can distinguish.
119
- *
120
- * Node A relays through `RedisRelay`, which `createApp` handed to
121
- * `HttpFactory`. Node B relays through the application's **own**
122
- * `RedisConnection`, which satisfies `PubSubRelay` structurally - two methods,
123
- * no adapter, and `@dunx/http` depending on `@dunx/infra` not at all.
111
+ * A publish on one node reaching a client connected to the other, exactly once.
112
+ * Node A relays through `RedisRelay`; node B through its own `RedisConnection`,
113
+ * which satisfies `PubSubRelay` structurally with no adapter.
124
114
  */
125
115
  async relayed(url: string): Promise<void> {
126
116
  const { logger } = this;
@@ -139,8 +129,8 @@ export class ChatDemo {
139
129
 
140
130
  try {
141
131
  logger.info(`node A on ${url}, node B on ${peerUrl}`);
142
- // The last chars, not the first: a v7 uuid starts with a timestamp, so two
143
- // ids minted in the same second share their leading digits.
132
+ // The last chars: a v7 uuid leads with a timestamp, so two minted in the
133
+ // same second share their leading digits.
144
134
  logger.info(
145
135
  `origins: A …${this.pubsub.origin.slice(-6)} / B …${peerPubsub.origin.slice(-6)} ` +
146
136
  '- what tells a node its own echoed frame',
@@ -152,9 +142,8 @@ export class ChatDemo {
152
142
  const said = 'across nodes';
153
143
  this.pubsub.publishEvent(Lobby.TOPIC, 'said', said);
154
144
  logger.info(`node B's client <- ${await onB.next()} (relayed via Redis)`);
155
- // Redis echoes a publish back to its publisher. Fanning that out again would
156
- // deliver twice on node A, so the origin check drops it - and these counts
157
- // are what would show it if it did not.
145
+ // Redis echoes a publish back to its publisher; the origin check drops it
146
+ // so node A does not deliver twice.
158
147
  await Bun.sleep(200);
159
148
  const delivered = (client: Client): number =>
160
149
  client.received.filter((frame) => frame.includes(said)).length;
@@ -14,10 +14,8 @@ import {
14
14
  import type { BunRequest } from 'bun';
15
15
  import { Lobby } from './lobby.service.js';
16
16
 
17
- /**
18
- * Served by the same `Bun.serve` call as the HTTP routes: `HttpFactory` discovers
19
- * it from `providers`, and `listen()` mounts the upgrade as a native route.
20
- */
17
+ /** Served by the same `Bun.serve` call as the HTTP routes: `listen()` mounts the
18
+ * upgrade as a native route. */
21
19
  @Gateway('/chat')
22
20
  export class ChatGateway {
23
21
  constructor(
@@ -26,13 +24,9 @@ export class ChatGateway {
26
24
  ) {}
27
25
 
28
26
  /**
29
- * Runs before the socket exists, and is the only place a connection can be
30
- * refused: return a `Response` and there is no upgrade. Anything else returned
31
- * becomes `socket.data.context`, which is how a room name or an authenticated
32
- * user gets carried onto the connection.
33
- *
34
- * It is handed the `BunRequest` because the upgrade really is a route - Bun
35
- * matched it - so headers, query and path params are all readable here.
27
+ * The only place a connection can be refused: return a `Response` and there is
28
+ * no upgrade. Anything else becomes `socket.data.context`. Handed the
29
+ * `BunRequest`, since Bun matched the upgrade as a route.
36
30
  */
37
31
  @OnUpgrade()
38
32
  upgrade(req: BunRequest): Response | { nickname: string } {
@@ -45,23 +39,18 @@ export class ChatGateway {
45
39
 
46
40
  @OnOpen()
47
41
  opened(socket: Socket): void {
48
- // Bun's own pub/sub - topics live in the runtime, not in a JavaScript map.
42
+ // Bun's own pub/sub: topics live in the runtime.
49
43
  socket.subscribe(Lobby.TOPIC);
50
44
  socket.send('welcome');
51
45
  }
52
46
 
53
47
  @OnMessage('say')
54
48
  say(text: string): { delivered: number } {
55
- // The broadcast reaches everyone subscribed; the return value is replied to
56
- // the sender under the same event name.
49
+ // Broadcast reaches every subscriber; the return value replies to the sender.
57
50
  return { delivered: this.lobby.broadcast(text) };
58
51
  }
59
52
 
60
- /**
61
- * Backpressure relieved: Bun buffered because the client was not reading fast
62
- * enough and has now flushed. This is where a server streaming to a slow
63
- * consumer resumes.
64
- */
53
+ /** Backpressure relieved: where a server streaming to a slow consumer resumes. */
65
54
  @OnDrain()
66
55
  drained(socket: Socket): void {
67
56
  this.logger.info(`${socket.data.path} drained, safe to resume sending`);
@@ -12,27 +12,18 @@ import * as schema from './schema.js';
12
12
 
13
13
  @Module({
14
14
  imports: [
15
- // `forRootAsync` is not a second mechanism: dunx settles every async factory
16
- // before the first constructor runs, so the connection is open and its pragmas
17
- // applied by the time a repository is built.
15
+ // The token comes first, unlike `forRoot`: which class a repository injects
16
+ // is only known once the factory has produced the options.
18
17
  //
19
- // The first argument is the token, unlike `forRoot`. The database class is
20
- // what a repository injects, and which class that is only becomes known once
21
- // the factory has produced the options - too late to register a provider
22
- // under it.
23
- //
24
- // `SyncSqliteOptions` rather than `SqliteOptions`, so this app runs SQLite in
25
- // **synchronous mode**: the token becomes `SyncDatabase`, and `transactionSync`
26
- // becomes reachable. `SqliteOptions` is the default and still what an app
27
- // wants if it might move to Postgres later - sync mode is SQLite for good.
18
+ // `SyncSqliteOptions` runs SQLite in synchronous mode, so the token is
19
+ // `SyncDatabase` and `transactionSync` is reachable. `SqliteOptions` is the
20
+ // default and what an app wants if it might move to Postgres.
28
21
  DbModule.forRootAsync(SyncDatabase, {
29
22
  useFactory: (config: AppConfigService) =>
30
23
  new SyncSqliteOptions({
31
- // Required, and the reason it is: this is the type argument that reaches
32
- // `SyncDatabase<typeof schema>` in every constructor below.
24
+ // Required: the type argument every constructor below sees.
33
25
  schema,
34
26
  filename: config.get('database').file,
35
- // The only place a pragma can run before the first query.
36
27
  pragmas: ['foreign_keys = ON'],
37
28
  }),
38
29
  inject: [AppConfigService],
@@ -41,10 +32,8 @@ import * as schema from './schema.js';
41
32
  controllers: [LedgerController],
42
33
  providers: [Ledger],
43
34
  /**
44
- * The drizzle handle, re-exported so every feature module that imports this one can
45
- * inject it. `DbModule` exports it to *this* module; naming it again is what passes
46
- * it on, and it is why a repository declares `imports: [DatabaseModule]` rather than
47
- * reaching into `@dunx/infra/db` itself.
35
+ * Re-exported so importers can inject it. `DbModule` exports to this module
36
+ * only; naming it again passes it on.
48
37
  */
49
38
  exports: [SyncDatabase, DbConnection, Ledger],
50
39
  })
@@ -23,17 +23,13 @@ const CreateEntry = z
23
23
  })
24
24
  .meta({ id: 'CreateEntry', description: 'A single ledger movement' });
25
25
 
26
- /** Both legs succeed or neither does - the rollback is the point of the route. */
27
26
  const Transfer = z
28
27
  .object({
29
28
  from: z.string().min(1).max(80),
30
29
  to: z.string().min(1).max(80),
31
30
  amount: z.number().int().positive(),
32
- /**
33
- * Throw between the two legs on purpose. The response is a 409 and the row
34
- * count is unchanged - which is the only way to see from outside that the
35
- * first insert was rolled back rather than committed.
36
- */
31
+ /** Throws between the two legs: a 409 with an unchanged row count is how
32
+ * the rollback is visible from outside. */
37
33
  fail: z.boolean().default(false),
38
34
  })
39
35
  .meta({ id: 'Transfer', description: 'Move an amount between two memos' });
@@ -44,11 +40,10 @@ const listEntries = {
44
40
  }),
45
41
  } as const;
46
42
  /**
47
- * The page query, written as zod here rather than shipped by `@dunx/infra/pagination`.
48
- * That package deliberately ships no schema - route validation targets Standard
49
- * Schema, so the app picks the library - and stating it here is what puts the
50
- * parameters in the OpenAPI document. `PAGINATION` supplies the bounds so they
51
- * cannot drift from what `parsePageOptions` would enforce.
43
+ * The page query as zod. `@dunx/infra/pagination` ships no schema - validation
44
+ * targets Standard Schema, so the app picks the library - and stating it here is
45
+ * what puts the parameters in the OpenAPI document. `PAGINATION` supplies the
46
+ * bounds so they cannot drift from `parsePageOptions`.
52
47
  */
53
48
  const pageQuery = z
54
49
  .object({
@@ -93,11 +88,8 @@ export class LedgerController {
93
88
  };
94
89
  }
95
90
 
96
- /**
97
- * The same rows as `GET /ledger`, walked by cursor. Declared **before** `/:id` for
98
- * readability only - `Bun.serve` matches a static segment ahead of a parameter, so
99
- * `/ledger/page` cannot be swallowed by `/ledger/:id`.
100
- */
91
+ /** Walked by cursor. Declared before `/:id` for readability only: `Bun.serve`
92
+ * matches a static segment ahead of a parameter. */
101
93
  @Get('/page', pagedEntries)
102
94
  page(input: Input<typeof pagedEntries>): Page<Entry> {
103
95
  return this.ledger.page(input.query);
@@ -120,11 +112,8 @@ export class LedgerController {
120
112
  return this.ledger.add(input.body.memo, input.body.amount);
121
113
  }
122
114
 
123
- /**
124
- * The failure path is the interesting one: `"fail": true` throws between the
125
- * two inserts, and the 409's `rows` is unchanged - proof the first leg was
126
- * rolled back rather than committed.
127
- */
115
+ /** `"fail": true` throws between the two inserts; the 409's unchanged `rows`
116
+ * is proof the first leg rolled back. */
128
117
  @Post('/transfer', transfer)
129
118
  async transfer(
130
119
  input: Input<typeof transfer>,
@@ -142,11 +131,9 @@ export class LedgerController {
142
131
  }
143
132
 
144
133
  /**
145
- * The same transfer with no `async` and no `await` on the path at all - the
146
- * handler returns a value, `@dunx/http` turns it into a `Response` without
147
- * allocating a promise, and SQLite answered on the same tick. What makes it
148
- * possible is `SyncSqliteOptions` in `DatabaseModule`; `transactionSync` will not
149
- * compile against the async mode's handle.
134
+ * The same transfer with no `async` anywhere: the handler returns a value and
135
+ * SQLite answers on the same tick. `SyncSqliteOptions` in `DatabaseModule` is
136
+ * what allows it - `transactionSync` will not compile against the async handle.
150
137
  */
151
138
  @Post('/transfer-sync', transfer)
152
139
  transferSync(input: Input<typeof transfer>): {