@dunx/create-app 0.3.1 → 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.
- package/dist/chunk-jgd5dmqh.js +698 -0
- package/dist/chunk-jgd5dmqh.js.map +12 -0
- package/dist/cli.js +68 -5
- package/dist/cli.js.map +3 -3
- package/dist/features.d.ts +74 -0
- package/dist/generate.d.ts +21 -0
- package/dist/index.js +1 -1
- package/dist/scaffold.d.ts +12 -1
- package/package.json +1 -1
- package/templates/base/_gitignore +5 -0
- package/templates/base/tsconfig.json +19 -0
- package/templates/features/auth/audit.service.ts +36 -0
- package/templates/features/auth/auth.demo.ts +173 -0
- package/templates/features/auth/auth.module.ts +54 -0
- package/templates/features/auth/auth.tables.ts +84 -0
- package/templates/features/auth/profile.controller.ts +37 -0
- package/templates/features/cache/cache.controller.ts +85 -0
- package/templates/features/cache/cache.module.ts +36 -0
- package/templates/features/cache/sessions.service.ts +90 -0
- package/templates/features/chat/chat.demo.ts +184 -0
- package/templates/features/chat/chat.gateway.ts +85 -0
- package/templates/features/chat/chat.module.ts +11 -0
- package/templates/features/chat/lobby.service.ts +20 -0
- package/templates/features/database/auth.schema.ts +75 -0
- package/templates/features/database/database.module.ts +39 -0
- package/templates/features/database/ledger.controller.ts +137 -0
- package/templates/features/database/ledger.service.ts +257 -0
- package/templates/features/database/schema.ts +27 -0
- package/templates/features/database/seeds/0001_ledger.seeder.ts +12 -0
- package/templates/features/database/seeds/0002_production_audit.seeder.ts +13 -0
- package/templates/features/docs/docs.demo.ts +130 -0
- package/templates/features/docs/docs.module.ts +9 -0
- package/templates/features/guards/auth.guard.ts +66 -0
- package/templates/features/guards/guards.demo.ts +75 -0
- package/templates/features/guards/guards.module.ts +16 -0
- package/templates/features/guards/reports.controller.ts +60 -0
- package/templates/features/guards/reports.service.ts +22 -0
- package/templates/features/health/health.controller.ts +65 -0
- package/templates/features/health/health.module.ts +5 -0
- package/templates/features/http/http.demo.ts +115 -0
- package/templates/features/http/http.module.ts +10 -0
- package/templates/features/http/request-log.ts +37 -0
- package/templates/features/jobs/jobs.controller.ts +102 -0
- package/templates/features/jobs/jobs.module.ts +35 -0
- package/templates/features/jobs/thumbnail.jobs.ts +53 -0
- package/templates/features/notes/notes.controller.ts +65 -0
- package/templates/features/notes/notes.module.ts +9 -0
- package/templates/features/notes/notes.service.ts +21 -0
- package/templates/features/pictures/images.controller.ts +74 -0
- package/templates/features/pictures/pictures.module.ts +22 -0
- package/templates/features/pictures/thumbnails.service.ts +108 -0
- package/templates/features/storage/files.controller.ts +130 -0
- package/templates/features/storage/storage.module.ts +21 -0
- package/templates/features/storage/uploads.service.ts +66 -0
- package/templates/features/storage/workspace.ts +33 -0
- package/templates/features/users/users.controller.ts +47 -0
- package/templates/features/users/users.demo.ts +62 -0
- package/templates/features/users/users.module.ts +11 -0
- package/templates/features/users/users.repository.ts +59 -0
- package/templates/features/users/users.schemas.ts +68 -0
- package/templates/features/users/users.service.ts +46 -0
- package/templates/minimal/_bunfig.toml +7 -0
- package/dist/chunk-rnjjb0bq.js +0 -69
- package/dist/chunk-rnjjb0bq.js.map +0 -10
- /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
|
+
}
|