@dunx/create-app 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/chunk-jgd5dmqh.js +698 -0
  2. package/dist/chunk-jgd5dmqh.js.map +12 -0
  3. package/dist/cli.js +68 -5
  4. package/dist/cli.js.map +3 -3
  5. package/dist/features.d.ts +74 -0
  6. package/dist/generate.d.ts +21 -0
  7. package/dist/index.js +1 -1
  8. package/dist/scaffold.d.ts +12 -1
  9. package/package.json +1 -1
  10. package/templates/base/_gitignore +5 -0
  11. package/templates/base/tsconfig.json +19 -0
  12. package/templates/features/auth/audit.service.ts +36 -0
  13. package/templates/features/auth/auth.demo.ts +173 -0
  14. package/templates/features/auth/auth.module.ts +54 -0
  15. package/templates/features/auth/auth.tables.ts +84 -0
  16. package/templates/features/auth/profile.controller.ts +37 -0
  17. package/templates/features/cache/cache.controller.ts +85 -0
  18. package/templates/features/cache/cache.module.ts +36 -0
  19. package/templates/features/cache/sessions.service.ts +90 -0
  20. package/templates/features/chat/chat.demo.ts +184 -0
  21. package/templates/features/chat/chat.gateway.ts +85 -0
  22. package/templates/features/chat/chat.module.ts +11 -0
  23. package/templates/features/chat/lobby.service.ts +20 -0
  24. package/templates/features/database/auth.schema.ts +75 -0
  25. package/templates/features/database/database.module.ts +39 -0
  26. package/templates/features/database/ledger.controller.ts +137 -0
  27. package/templates/features/database/ledger.service.ts +257 -0
  28. package/templates/features/database/schema.ts +27 -0
  29. package/templates/features/database/seeds/0001_ledger.seeder.ts +12 -0
  30. package/templates/features/database/seeds/0002_production_audit.seeder.ts +13 -0
  31. package/templates/features/docs/docs.demo.ts +130 -0
  32. package/templates/features/docs/docs.module.ts +9 -0
  33. package/templates/features/guards/auth.guard.ts +66 -0
  34. package/templates/features/guards/guards.demo.ts +75 -0
  35. package/templates/features/guards/guards.module.ts +16 -0
  36. package/templates/features/guards/reports.controller.ts +60 -0
  37. package/templates/features/guards/reports.service.ts +22 -0
  38. package/templates/features/health/health.controller.ts +65 -0
  39. package/templates/features/health/health.module.ts +5 -0
  40. package/templates/features/http/http.demo.ts +115 -0
  41. package/templates/features/http/http.module.ts +10 -0
  42. package/templates/features/http/request-log.ts +37 -0
  43. package/templates/features/jobs/jobs.controller.ts +102 -0
  44. package/templates/features/jobs/jobs.module.ts +35 -0
  45. package/templates/features/jobs/thumbnail.jobs.ts +53 -0
  46. package/templates/features/notes/notes.controller.ts +65 -0
  47. package/templates/features/notes/notes.module.ts +9 -0
  48. package/templates/features/notes/notes.service.ts +21 -0
  49. package/templates/features/pictures/images.controller.ts +74 -0
  50. package/templates/features/pictures/pictures.module.ts +22 -0
  51. package/templates/features/pictures/thumbnails.service.ts +108 -0
  52. package/templates/features/storage/files.controller.ts +130 -0
  53. package/templates/features/storage/storage.module.ts +21 -0
  54. package/templates/features/storage/uploads.service.ts +66 -0
  55. package/templates/features/storage/workspace.ts +33 -0
  56. package/templates/features/users/users.controller.ts +47 -0
  57. package/templates/features/users/users.demo.ts +62 -0
  58. package/templates/features/users/users.module.ts +11 -0
  59. package/templates/features/users/users.repository.ts +59 -0
  60. package/templates/features/users/users.schemas.ts +68 -0
  61. package/templates/features/users/users.service.ts +46 -0
  62. package/templates/minimal/_bunfig.toml +7 -0
  63. package/dist/chunk-rnjjb0bq.js +0 -69
  64. package/dist/chunk-rnjjb0bq.js.map +0 -10
  65. /package/templates/{minimal/bunfig.toml → base/_bunfig.toml} +0 -0
@@ -0,0 +1,184 @@
1
+ import { Logger, Module } from '@dunx/core';
2
+ import { HttpFactory, PubSub, type HttpApp } from '@dunx/http';
3
+ import { isConnectionError, RedisConnection } from '@dunx/infra/redis';
4
+ import { RELAY_CHANNEL } from '../config.js';
5
+ import { ChatGateway } from './chat.gateway.js';
6
+ import { Lobby } from './lobby.service.js';
7
+
8
+ interface Client {
9
+ next(): Promise<string>;
10
+ send(event: string, data: unknown): void;
11
+ close(): void;
12
+ /** Every frame this socket ever received, so a *second* delivery is visible. */
13
+ readonly received: readonly string[];
14
+ }
15
+
16
+ /** A real `new WebSocket()`, with a deadline so a stall fails instead of hanging. */
17
+ const connect = async (base: string): Promise<Client> => {
18
+ const socket = new WebSocket(
19
+ new URL('chat', base).href.replace('http', 'ws'),
20
+ );
21
+ const frames: string[] = [];
22
+ const received: string[] = [];
23
+ const waiting: ((frame: string) => void)[] = [];
24
+
25
+ socket.addEventListener('message', (event: MessageEvent) => {
26
+ const frame = String(event.data);
27
+ received.push(frame);
28
+ const waiter = waiting.shift();
29
+ if (waiter) waiter(frame);
30
+ else frames.push(frame);
31
+ });
32
+ await new Promise<void>((resolve, reject) => {
33
+ socket.addEventListener('open', () => resolve(), { once: true });
34
+ setTimeout(() => reject(new Error('the socket never opened')), 2000);
35
+ });
36
+
37
+ return {
38
+ next: () =>
39
+ new Promise<string>((resolve, reject) => {
40
+ const queued = frames.shift();
41
+ if (queued !== undefined) {
42
+ resolve(queued);
43
+ return;
44
+ }
45
+ const timer = setTimeout(
46
+ () => reject(new Error('no frame arrived')),
47
+ 2000,
48
+ );
49
+ waiting.push((frame) => {
50
+ clearTimeout(timer);
51
+ resolve(frame);
52
+ });
53
+ }),
54
+ send: (event, data) => socket.send(JSON.stringify({ event, data })),
55
+ close: () => socket.close(),
56
+ received,
57
+ };
58
+ };
59
+
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.
67
+ */
68
+ @Module({ providers: [ChatGateway, Lobby] })
69
+ class PeerNode {}
70
+
71
+ export class ChatDemo {
72
+ constructor(
73
+ private readonly pubsub: PubSub,
74
+ private readonly logger: Logger,
75
+ private readonly redis: RedisConnection,
76
+ ) {}
77
+
78
+ async demonstrate(app: HttpApp, url: string): Promise<void> {
79
+ const { logger } = this;
80
+ logger.info(
81
+ `gateway paths: ${JSON.stringify(app.gatewayPaths)} - setGlobalPrefix moves routes, not gateways`,
82
+ );
83
+
84
+ const [ada, grace] = await Promise.all([connect(url), connect(url)]);
85
+ logger.info(
86
+ `two clients connected: ${await ada.next()} / ${await grace.next()}`,
87
+ );
88
+
89
+ ada.send('say', 'one server, two protocols');
90
+ logger.info(
91
+ `grace <- ${await grace.next()} (the broadcast, Bun native pub/sub)`,
92
+ );
93
+ logger.info(
94
+ `ada <- ${await ada.next()} (a server publish reaches the sender too)`,
95
+ );
96
+ logger.info(
97
+ `ada <- ${await ada.next()} (then what the handler returned, as a reply)`,
98
+ );
99
+ logger.info(
100
+ `"${Lobby.TOPIC}" subscribers: ${this.pubsub.subscriberCount(Lobby.TOPIC)}`,
101
+ );
102
+
103
+ const alsoHttp = await fetch(new URL('api/notes', url));
104
+ logger.info(
105
+ `the same server still answers GET /api/notes -> ${alsoHttp.status}`,
106
+ );
107
+
108
+ ada.close();
109
+ grace.close();
110
+ // Long enough for @OnClose to run before the tour moves on.
111
+ await Bun.sleep(20);
112
+ }
113
+
114
+ /**
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.
124
+ */
125
+ async relayed(url: string): Promise<void> {
126
+ const { logger } = this;
127
+ if (!(await this.#redisUp())) {
128
+ logger.warn('skipping the relay demo: no Redis to relay through');
129
+ logger.info(
130
+ 'the app booted anyway and fan-out stayed local - that is the degraded path',
131
+ );
132
+ return;
133
+ }
134
+
135
+ const peer = await HttpFactory.create(PeerNode, { requestLogging: false });
136
+ const peerUrl = await peer.listen(0);
137
+ const peerPubsub = peer.get(PubSub);
138
+ await peerPubsub.relayThrough(this.redis, { channel: RELAY_CHANNEL });
139
+
140
+ try {
141
+ 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.
144
+ logger.info(
145
+ `origins: A …${this.pubsub.origin.slice(-6)} / B …${peerPubsub.origin.slice(-6)} ` +
146
+ '- what tells a node its own echoed frame',
147
+ );
148
+
149
+ const [onA, onB] = await Promise.all([connect(url), connect(peerUrl)]);
150
+ await Promise.all([onA.next(), onB.next()]);
151
+
152
+ const said = 'across nodes';
153
+ this.pubsub.publishEvent(Lobby.TOPIC, 'said', said);
154
+ 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.
158
+ await Bun.sleep(200);
159
+ const delivered = (client: Client): number =>
160
+ client.received.filter((frame) => frame.includes(said)).length;
161
+ logger.info(
162
+ `deliveries of "${said}": A ${delivered(onA)}, B ${delivered(onB)} ` +
163
+ '(one each - the echo was dropped, not fanned out again)',
164
+ );
165
+
166
+ onA.close();
167
+ onB.close();
168
+ await Bun.sleep(20);
169
+ } finally {
170
+ await peer.shutdown();
171
+ }
172
+ }
173
+
174
+ /** A relay demo needs a broker; an absent one is a skip, not a failure. */
175
+ async #redisUp(): Promise<boolean> {
176
+ try {
177
+ await this.redis.ping();
178
+ return true;
179
+ } catch (error) {
180
+ if (!isConnectionError(error)) throw error;
181
+ return false;
182
+ }
183
+ }
184
+ }
@@ -0,0 +1,85 @@
1
+ import { Logger } from '@dunx/core';
2
+ import {
3
+ Gateway,
4
+ HttpStatusCode,
5
+ OnClose,
6
+ OnDrain,
7
+ OnMessage,
8
+ OnOpen,
9
+ OnPing,
10
+ OnPong,
11
+ OnUpgrade,
12
+ type Socket,
13
+ } from '@dunx/http';
14
+ import type { BunRequest } from 'bun';
15
+ import { Lobby } from './lobby.service.js';
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
+ */
21
+ @Gateway('/chat')
22
+ export class ChatGateway {
23
+ constructor(
24
+ private readonly lobby: Lobby,
25
+ private readonly logger: Logger,
26
+ ) {}
27
+
28
+ /**
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.
36
+ */
37
+ @OnUpgrade()
38
+ upgrade(req: BunRequest): Response | { nickname: string } {
39
+ const nickname = new URL(req.url).searchParams.get('as') ?? 'anonymous';
40
+ if (nickname === 'banned') {
41
+ return new Response('nope', { status: HttpStatusCode.FORBIDDEN });
42
+ }
43
+ return { nickname };
44
+ }
45
+
46
+ @OnOpen()
47
+ opened(socket: Socket): void {
48
+ // Bun's own pub/sub - topics live in the runtime, not in a JavaScript map.
49
+ socket.subscribe(Lobby.TOPIC);
50
+ socket.send('welcome');
51
+ }
52
+
53
+ @OnMessage('say')
54
+ 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.
57
+ return { delivered: this.lobby.broadcast(text) };
58
+ }
59
+
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
+ */
65
+ @OnDrain()
66
+ drained(socket: Socket): void {
67
+ this.logger.info(`${socket.data.path} drained, safe to resume sending`);
68
+ }
69
+
70
+ /** Bun answers with a pong itself; this is for observing liveness. */
71
+ @OnPing()
72
+ pinged(_data: Buffer, socket: Socket): void {
73
+ this.logger.info(`${socket.data.path} pinged`);
74
+ }
75
+
76
+ @OnPong()
77
+ ponged(_data: Buffer, socket: Socket): void {
78
+ this.logger.info(`${socket.data.path} ponged`);
79
+ }
80
+
81
+ @OnClose()
82
+ closed(socket: Socket, code: number): void {
83
+ this.logger.info(`${socket.data.path} closed with ${code}`);
84
+ }
85
+ }
@@ -0,0 +1,11 @@
1
+ import { Module } from '@dunx/core';
2
+ import { ChatDemo } from './chat.demo.js';
3
+ import { ChatGateway } from './chat.gateway.js';
4
+ import { Lobby } from './lobby.service.js';
5
+
6
+ // A gateway is declared in `providers`, next to the services it injects - there is
7
+ // no separate list for it, and no second module to configure.
8
+ @Module({
9
+ providers: [ChatGateway, Lobby, ChatDemo],
10
+ })
11
+ export class ChatModule {}
@@ -0,0 +1,20 @@
1
+ import { PubSub } from '@dunx/http';
2
+
3
+ /**
4
+ * A plain service that publishes without holding a socket. `PubSub` is bound by
5
+ * `HttpFactory` around the root module, so nothing has to be imported or
6
+ * registered for this to resolve - listing it in `providers` would be the
7
+ * container's duplicate-binding error.
8
+ */
9
+ export class Lobby {
10
+ static readonly TOPIC = 'lobby';
11
+
12
+ readonly said: string[] = [];
13
+
14
+ constructor(private readonly pubsub: PubSub) {}
15
+
16
+ broadcast(text: string): number {
17
+ this.said.push(text);
18
+ return this.pubsub.publishEvent(Lobby.TOPIC, 'said', text);
19
+ }
20
+ }
@@ -0,0 +1,75 @@
1
+ import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
2
+
3
+ /**
4
+ * better-auth's four tables, plus the columns its `admin` plugin adds. **Generated,
5
+ * not written**: `bunx @better-auth/cli generate` emits this from the very options
6
+ * `AuthModule` is configured with, which is why `@dunx/auth` ships no copy - the
7
+ * shape follows the plugins an app enables.
8
+ *
9
+ * They live in the app's one schema module, so `drizzle({ client, schema })` carries
10
+ * them and `drizzleDatabase(connection)` needs no schema argument.
11
+ */
12
+ const stamp = () =>
13
+ integer({ mode: 'timestamp_ms' })
14
+ .notNull()
15
+ .$defaultFn(() => new Date());
16
+
17
+ export const user = sqliteTable('user', {
18
+ id: text().primaryKey(),
19
+ name: text().notNull(),
20
+ email: text().notNull().unique(),
21
+ emailVerified: integer({ mode: 'boolean' }).notNull().default(false),
22
+ image: text(),
23
+ /** `admin` plugin. Comma-separated for more than one - `rolesOf` splits it. */
24
+ role: text(),
25
+ banned: integer({ mode: 'boolean' }),
26
+ banReason: text(),
27
+ banExpires: integer({ mode: 'timestamp_ms' }),
28
+ createdAt: stamp(),
29
+ updatedAt: stamp(),
30
+ });
31
+
32
+ export const session = sqliteTable('session', {
33
+ id: text().primaryKey(),
34
+ token: text().notNull().unique(),
35
+ userId: text()
36
+ .notNull()
37
+ .references(() => user.id, { onDelete: 'cascade' }),
38
+ expiresAt: integer({ mode: 'timestamp_ms' }).notNull(),
39
+ ipAddress: text(),
40
+ userAgent: text(),
41
+ /** `admin` plugin. */
42
+ impersonatedBy: text(),
43
+ createdAt: stamp(),
44
+ updatedAt: stamp(),
45
+ });
46
+
47
+ export const account = sqliteTable('account', {
48
+ id: text().primaryKey(),
49
+ accountId: text().notNull(),
50
+ /** `credential` for email/password, else the social provider's id. */
51
+ providerId: text().notNull(),
52
+ userId: text()
53
+ .notNull()
54
+ .references(() => user.id, { onDelete: 'cascade' }),
55
+ accessToken: text(),
56
+ refreshToken: text(),
57
+ idToken: text(),
58
+ accessTokenExpiresAt: integer({ mode: 'timestamp_ms' }),
59
+ refreshTokenExpiresAt: integer({ mode: 'timestamp_ms' }),
60
+ scope: text(),
61
+ password: text(),
62
+ createdAt: stamp(),
63
+ updatedAt: stamp(),
64
+ });
65
+
66
+ export const verification = sqliteTable('verification', {
67
+ id: text().primaryKey(),
68
+ identifier: text().notNull(),
69
+ value: text().notNull(),
70
+ expiresAt: integer({ mode: 'timestamp_ms' }).notNull(),
71
+ createdAt: stamp(),
72
+ updatedAt: stamp(),
73
+ });
74
+
75
+ export type AuthUser = typeof user.$inferSelect;
@@ -0,0 +1,39 @@
1
+ import { Module } from '@dunx/core';
2
+ import { DbModule, SyncDatabase, SyncSqliteOptions } from '@dunx/infra/db';
3
+ import { AppConfigService } from '../config.js';
4
+ import { LedgerController } from './ledger.controller.js';
5
+ import { Ledger } from './ledger.service.js';
6
+ import * as schema from './schema.js';
7
+
8
+ @Module({
9
+ imports: [
10
+ // `forRootAsync` is not a second mechanism: dunx settles every async factory
11
+ // before the first constructor runs, so the connection is open and its pragmas
12
+ // applied by the time a repository is built.
13
+ //
14
+ // The first argument is the token, unlike `forRoot`. The database class is
15
+ // what a repository injects, and which class that is only becomes known once
16
+ // the factory has produced the options - too late to register a provider
17
+ // under it.
18
+ //
19
+ // `SyncSqliteOptions` rather than `SqliteOptions`, so this app runs SQLite in
20
+ // **synchronous mode**: the token becomes `SyncDatabase`, and `transactionSync`
21
+ // becomes reachable. `SqliteOptions` is the default and still what an app
22
+ // wants if it might move to Postgres later - sync mode is SQLite for good.
23
+ DbModule.forRootAsync(SyncDatabase, {
24
+ useFactory: (config: AppConfigService) =>
25
+ new SyncSqliteOptions({
26
+ // Required, and the reason it is: this is the type argument that reaches
27
+ // `SyncDatabase<typeof schema>` in every constructor below.
28
+ schema,
29
+ filename: config.get('database').file,
30
+ // The only place a pragma can run before the first query.
31
+ pragmas: ['foreign_keys = ON'],
32
+ }),
33
+ inject: [AppConfigService],
34
+ }),
35
+ ],
36
+ controllers: [LedgerController],
37
+ providers: [Ledger],
38
+ })
39
+ export class DatabaseModule {}
@@ -0,0 +1,137 @@
1
+ import {
2
+ Controller,
3
+ Delete,
4
+ Get,
5
+ HttpError,
6
+ HttpStatusCode,
7
+ Post,
8
+ type Input,
9
+ } from '@dunx/http';
10
+ import { z } from 'zod';
11
+ import { Ledger } from './ledger.service.js';
12
+ import type { Entry } from './schema.js';
13
+
14
+ const EntryIndex = z
15
+ .object({ id: z.coerce.number().int().min(1) })
16
+ .meta({ id: 'EntryIndex', title: 'A ledger entry id in the path' });
17
+
18
+ const CreateEntry = z
19
+ .object({
20
+ memo: z.string().min(1).max(80),
21
+ amount: z.number().int(),
22
+ })
23
+ .meta({ id: 'CreateEntry', title: 'A single ledger movement' });
24
+
25
+ /** Both legs succeed or neither does - the rollback is the point of the route. */
26
+ const Transfer = z
27
+ .object({
28
+ from: z.string().min(1).max(80),
29
+ to: z.string().min(1).max(80),
30
+ amount: z.number().int().positive(),
31
+ /**
32
+ * Throw between the two legs on purpose. The response is a 409 and the row
33
+ * count is unchanged - which is the only way to see from outside that the
34
+ * first insert was rolled back rather than committed.
35
+ */
36
+ fail: z.boolean().default(false),
37
+ })
38
+ .meta({ id: 'Transfer', title: 'Move an amount between two memos' });
39
+
40
+ const listEntries = {
41
+ query: z.object({
42
+ limit: z.coerce.number().int().min(1).max(100).default(20),
43
+ }),
44
+ } as const;
45
+ const oneEntry = { params: EntryIndex } as const;
46
+ const createEntry = { body: CreateEntry } as const;
47
+ const transfer = { body: Transfer } as const;
48
+
49
+ @Controller('ledger')
50
+ export class LedgerController {
51
+ constructor(private readonly ledger: Ledger) {}
52
+
53
+ @Get('/', listEntries)
54
+ list(input: Input<typeof listEntries>): {
55
+ entries: readonly Entry[];
56
+ balance: number;
57
+ } {
58
+ return {
59
+ entries: this.ledger.list(input.query.limit),
60
+ balance: this.ledger.balance(),
61
+ };
62
+ }
63
+
64
+ @Get('/:id', oneEntry)
65
+ one(input: Input<typeof oneEntry>): Entry {
66
+ const entry = this.ledger.find(input.params.id);
67
+ if (entry === undefined) {
68
+ throw new HttpError(
69
+ HttpStatusCode.NOT_FOUND,
70
+ `No ledger entry ${input.params.id}`,
71
+ );
72
+ }
73
+ return entry;
74
+ }
75
+
76
+ @Post('/', createEntry)
77
+ create(input: Input<typeof createEntry>): Entry {
78
+ return this.ledger.add(input.body.memo, input.body.amount);
79
+ }
80
+
81
+ /**
82
+ * The failure path is the interesting one: `"fail": true` throws between the
83
+ * two inserts, and the 409's `rows` is unchanged - proof the first leg was
84
+ * rolled back rather than committed.
85
+ */
86
+ @Post('/transfer', transfer)
87
+ async transfer(
88
+ input: Input<typeof transfer>,
89
+ ): Promise<{ balance: number; rows: number }> {
90
+ const { from, to, amount, fail } = input.body;
91
+ try {
92
+ const balance = await this.ledger.transfer(from, to, amount, fail);
93
+ return { balance, rows: this.ledger.rows() };
94
+ } catch (error) {
95
+ throw new HttpError(
96
+ HttpStatusCode.CONFLICT,
97
+ `${(error as Error).message} - rolled back, still ${this.ledger.rows()} rows`,
98
+ );
99
+ }
100
+ }
101
+
102
+ /**
103
+ * The same transfer with no `async` and no `await` on the path at all - the
104
+ * handler returns a value, `@dunx/http` turns it into a `Response` without
105
+ * allocating a promise, and SQLite answered on the same tick. What makes it
106
+ * possible is `SyncSqliteOptions` in `DatabaseModule`; `transactionSync` will not
107
+ * compile against the async mode's handle.
108
+ */
109
+ @Post('/transfer-sync', transfer)
110
+ transferSync(input: Input<typeof transfer>): {
111
+ balance: number;
112
+ rows: number;
113
+ } {
114
+ const { from, to, amount, fail } = input.body;
115
+ try {
116
+ const balance = this.ledger.transferSync(from, to, amount, fail);
117
+ return { balance, rows: this.ledger.rows() };
118
+ } catch (error) {
119
+ throw new HttpError(
120
+ HttpStatusCode.CONFLICT,
121
+ `${(error as Error).message} - rolled back, still ${this.ledger.rows()} rows`,
122
+ );
123
+ }
124
+ }
125
+
126
+ @Delete('/:id', oneEntry)
127
+ remove(input: Input<typeof oneEntry>): { deleted: boolean } {
128
+ const deleted = this.ledger.remove(input.params.id);
129
+ if (!deleted) {
130
+ throw new HttpError(
131
+ HttpStatusCode.NOT_FOUND,
132
+ `No ledger entry ${input.params.id}`,
133
+ );
134
+ }
135
+ return { deleted };
136
+ }
137
+ }