@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.
- package/dist/features.d.ts +6 -11
- package/package.json +1 -1
- package/templates/features/assets/assets.module.ts +6 -15
- package/templates/features/auth/auth.demo.ts +7 -19
- package/templates/features/auth/auth.module.ts +16 -31
- package/templates/features/auth/auth.tables.ts +7 -15
- package/templates/features/cache/cache.module.ts +7 -13
- package/templates/features/chat/chat.demo.ts +10 -21
- package/templates/features/chat/chat.gateway.ts +8 -19
- package/templates/features/database/database.module.ts +8 -19
- package/templates/features/database/ledger.controller.ts +13 -26
- package/templates/features/database/ledger.service.ts +16 -44
- package/templates/features/docs/docs.demo.ts +13 -32
- package/templates/features/health/health.module.ts +10 -19
- package/templates/features/health/indicators.ts +10 -26
- package/templates/features/http/compression.demo.ts +7 -15
- package/templates/features/http/http.demo.ts +7 -13
- package/templates/features/http/request-trail.ts +5 -8
- package/templates/features/jobs/jobs.controller.ts +7 -18
- package/templates/features/jobs/jobs.module.ts +8 -18
- package/templates/features/jobs/jobs.processor.ts +5 -13
- package/templates/features/schedule/maintenance.service.ts +11 -29
- package/templates/features/schedule/schedule.module.ts +3 -7
- package/templates/features/storage/files.controller.ts +10 -19
- package/templates/features/throttle/limits.controller.ts +5 -12
- package/templates/features/throttle/throttle.module.ts +8 -26
- package/templates/features/upstream/upstream.demo.ts +6 -15
- package/templates/features/upstream/upstream.module.ts +4 -13
- package/templates/features/users/users.repository.ts +4 -13
- package/templates/features/users/users.schemas.ts +11 -33
- package/templates/minimal/src/app.module.ts +0 -5
- package/templates/minimal/src/app.test.ts +0 -5
- package/templates/minimal/src/greetings.controller.ts +2 -12
- package/templates/minimal/src/greetings.service.ts +2 -10
- package/templates/minimal/src/main.ts +0 -5
package/dist/features.d.ts
CHANGED
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The features a generated app can be composed from, each
|
|
3
|
-
* `examples/full
|
|
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
|
-
*
|
|
6
|
-
*
|
|
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/package.json
CHANGED
|
@@ -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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
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
|
|
30
|
-
// asset nobody can flush
|
|
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
|
-
*
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
* `
|
|
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
|
|
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
|
|
19
|
-
//
|
|
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`
|
|
24
|
-
//
|
|
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
|
|
31
|
-
//
|
|
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
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
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
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
// `
|
|
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
|
-
// `
|
|
69
|
-
//
|
|
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`
|
|
8
|
-
*
|
|
9
|
-
* `bunx @better-auth/cli generate` and then `drizzle-kit
|
|
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
|
-
*
|
|
12
|
-
*
|
|
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
|
-
*
|
|
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
|
-
//
|
|
10
|
-
//
|
|
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`
|
|
16
|
-
//
|
|
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
|
-
//
|
|
21
|
-
//
|
|
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
|
|
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
|
|
62
|
-
* `PubSub`
|
|
63
|
-
*
|
|
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
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
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
|
|
143
|
-
//
|
|
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
|
|
156
|
-
//
|
|
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
|
-
*
|
|
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
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
16
|
-
//
|
|
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
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
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
|
|
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
|
-
*
|
|
45
|
-
*
|
|
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
|
-
*
|
|
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
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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`
|
|
146
|
-
*
|
|
147
|
-
*
|
|
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>): {
|