@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,173 @@
1
+ import { Auth } from '@dunx/auth';
2
+ import { Logger } from '@dunx/core';
3
+ import { SyncDatabase } from '@dunx/infra/db';
4
+ import { eq } from 'drizzle-orm';
5
+ import * as schema from '../database/schema.js';
6
+ import { user } from '../database/schema.js';
7
+
8
+ const CREDENTIALS = {
9
+ email: 'ada@example.com',
10
+ password: 'correct horse battery',
11
+ name: 'Ada',
12
+ };
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
+ */
19
+ export class AuthDemo {
20
+ constructor(
21
+ private readonly logger: Logger,
22
+ private readonly auth: Auth,
23
+ private readonly db: SyncDatabase<typeof schema>,
24
+ ) {}
25
+
26
+ /** What `Origin` has to be for better-auth's CSRF check - see {@link post}. */
27
+ #origin = '';
28
+
29
+ async demonstrate(url: string): Promise<void> {
30
+ 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
+ this.#origin = (await this.auth.$context).baseURL;
34
+ this.logger.info(
35
+ `better-auth ${this.auth.options.basePath} mounted, hashing with Bun.password bcrypt`,
36
+ );
37
+
38
+ await this.report(
39
+ 'POST /api/auth/sign-up/email',
40
+ await this.post(base, 'sign-up/email', CREDENTIALS),
41
+ );
42
+
43
+ const signIn = await this.post(base, 'sign-in/email', {
44
+ email: CREDENTIALS.email,
45
+ password: CREDENTIALS.password,
46
+ });
47
+ await this.report('POST /api/auth/sign-in/email', signIn);
48
+
49
+ // The `bearer` plugin returns the session token in a header, so a server-side
50
+ // client authenticates without a cookie jar.
51
+ const token = signIn.headers.get('set-auth-token') ?? '';
52
+ const cookie = signIn.headers
53
+ .getSetCookie()
54
+ .map((entry) => entry.split(';')[0])
55
+ .join('; ');
56
+
57
+ await this.call('GET /api/profile, no credentials', base, '/api/profile');
58
+ await this.call(
59
+ 'GET /api/profile with the session cookie',
60
+ base,
61
+ '/api/profile',
62
+ {
63
+ cookie,
64
+ },
65
+ );
66
+ await this.call(
67
+ 'GET /api/profile with a bearer token',
68
+ base,
69
+ '/api/profile',
70
+ {
71
+ authorization: `Bearer ${token}`,
72
+ },
73
+ );
74
+
75
+ await this.call(
76
+ '@Roles("admin") GET /api/profile/audit as "user"',
77
+ base,
78
+ '/api/profile/audit',
79
+ {
80
+ cookie,
81
+ },
82
+ );
83
+
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.
86
+ this.db
87
+ .update(user)
88
+ .set({ role: 'admin' })
89
+ .where(eq(user.email, CREDENTIALS.email))
90
+ .run();
91
+ this.logger.info(
92
+ 'promoted ada@example.com to role "admin" through drizzle',
93
+ );
94
+
95
+ await this.call(
96
+ '@Roles("admin") GET /api/profile/audit as "admin"',
97
+ base,
98
+ '/api/profile/audit',
99
+ {
100
+ cookie,
101
+ },
102
+ );
103
+ await this.call(
104
+ '@Public() GET /api/profile/anonymous, cookie ignored',
105
+ base,
106
+ '/api/profile/anonymous',
107
+ {
108
+ cookie,
109
+ },
110
+ );
111
+
112
+ await this.report(
113
+ 'POST /api/auth/sign-out',
114
+ await this.post(base, 'sign-out', {}, { cookie }),
115
+ );
116
+ await this.call(
117
+ 'GET /api/profile after signing out',
118
+ base,
119
+ '/api/profile',
120
+ {
121
+ cookie,
122
+ },
123
+ );
124
+
125
+ // The instance is injectable, so a service can ask better-auth directly rather
126
+ // than going over HTTP.
127
+ const session = await this.auth.api.getSession({
128
+ headers: new Headers({ cookie }),
129
+ });
130
+ this.logger.info(
131
+ `auth.api.getSession() after sign-out -> ${session === null ? 'null' : 'still live'}`,
132
+ );
133
+ }
134
+
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
+ */
141
+ private post(
142
+ base: string,
143
+ endpoint: string,
144
+ body: unknown,
145
+ headers: Record<string, string> = {},
146
+ ): Promise<Response> {
147
+ return fetch(`${base}/api/auth/${endpoint}`, {
148
+ method: 'POST',
149
+ headers: {
150
+ 'content-type': 'application/json',
151
+ origin: this.#origin,
152
+ ...headers,
153
+ },
154
+ body: JSON.stringify(body),
155
+ });
156
+ }
157
+
158
+ private async call(
159
+ label: string,
160
+ base: string,
161
+ path: string,
162
+ headers: Record<string, string> = {},
163
+ ): Promise<void> {
164
+ await this.report(label, await fetch(`${base}${path}`, { headers }));
165
+ }
166
+
167
+ private async report(label: string, response: Response): Promise<void> {
168
+ const text = await response.text();
169
+ this.logger.info(
170
+ `${label} -> ${response.status} ${text.length > 220 ? `${text.slice(0, 220)}…` : text}`,
171
+ );
172
+ }
173
+ }
@@ -0,0 +1,54 @@
1
+ import { AuthModule, bunPassword } from '@dunx/auth';
2
+ import { drizzleDatabase } from '@dunx/auth/drizzle';
3
+ import { Module } from '@dunx/core';
4
+ import { DbConnection } from '@dunx/infra/db';
5
+ import { admin, bearer } from 'better-auth/plugins';
6
+ import { AppConfigService } from '../config.js';
7
+ import { AuthDemo } from './auth.demo.js';
8
+ import { AuthTables } from './auth.tables.js';
9
+ import { Audit } from './audit.service.js';
10
+ import { ProfileController } from './profile.controller.js';
11
+
12
+ /** Named for the feature rather than the package, so `AuthModule` still means `@dunx/auth`'s. */
13
+ @Module({
14
+ imports: [
15
+ // `forRootAsync` because the secret and the base URL come from the validated
16
+ // config, and the database from the connection `DatabaseModule` already opened -
17
+ // none of which a zero-argument factory could reach.
18
+ AuthModule.forRootAsync(
19
+ {
20
+ useFactory: (config: AppConfigService, connection: DbConnection) => ({
21
+ secret: config.get('auth').secret,
22
+ baseURL: `http://localhost:${config.get('port')}`,
23
+ // What better-auth matches an incoming pathname against. `app.setGlobalPrefix('api')`
24
+ // is what makes the mounted `/auth` route answer here.
25
+ basePath: '/api/auth',
26
+ // The app's one drizzle handle. No second pool, no second SQLite file, and
27
+ // the connection still closes exactly once, last.
28
+ database: drizzleDatabase(connection),
29
+ // `password: bunPassword` is what `AuthModule` would apply anyway when
30
+ // `emailAndPassword` is on and no hasher is given - named here so it is
31
+ // visible. better-auth's own default is a pure-JavaScript scrypt;
32
+ // `bunPassword` is `Bun.password`'s native bcrypt, which is Rule 1's
33
+ // first half. Bun pre-hashes, so bcrypt's 72-byte cap is a non-issue.
34
+ emailAndPassword: {
35
+ enabled: true,
36
+ minPasswordLength: 8,
37
+ password: bunPassword,
38
+ },
39
+ // `admin` puts `role` on the user, which `@Roles()` then reads. `bearer`
40
+ // lets a non-browser client send `Authorization: Bearer <token>` instead of
41
+ // a cookie - which is what the tour does.
42
+ plugins: [admin(), bearer()],
43
+ }),
44
+ inject: [AppConfigService, DbConnection] as const,
45
+ },
46
+ // The route path. The global prefix turns it into `/api/auth`, the `basePath`
47
+ // above - see AuthOptions.mountAt.
48
+ '/auth',
49
+ ),
50
+ ],
51
+ controllers: [ProfileController],
52
+ providers: [AuthTables, Audit, AuthDemo],
53
+ })
54
+ export class AccountsModule {}
@@ -0,0 +1,84 @@
1
+ import { Logger, type OnInit } from '@dunx/core';
2
+ import { SyncDatabase } from '@dunx/infra/db';
3
+ import { sql } from 'drizzle-orm';
4
+ import * as schema from '../database/schema.js';
5
+
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.
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.
18
+ */
19
+ const TABLES = [
20
+ sql`CREATE TABLE IF NOT EXISTS user (
21
+ id TEXT PRIMARY KEY NOT NULL,
22
+ name TEXT NOT NULL,
23
+ email TEXT NOT NULL UNIQUE,
24
+ emailVerified INTEGER NOT NULL DEFAULT 0,
25
+ image TEXT,
26
+ role TEXT,
27
+ banned INTEGER,
28
+ banReason TEXT,
29
+ banExpires INTEGER,
30
+ createdAt INTEGER NOT NULL,
31
+ updatedAt INTEGER NOT NULL
32
+ )`,
33
+ sql`CREATE TABLE IF NOT EXISTS session (
34
+ id TEXT PRIMARY KEY NOT NULL,
35
+ token TEXT NOT NULL UNIQUE,
36
+ userId TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
37
+ expiresAt INTEGER NOT NULL,
38
+ ipAddress TEXT,
39
+ userAgent TEXT,
40
+ impersonatedBy TEXT,
41
+ createdAt INTEGER NOT NULL,
42
+ updatedAt INTEGER NOT NULL
43
+ )`,
44
+ sql`CREATE TABLE IF NOT EXISTS account (
45
+ id TEXT PRIMARY KEY NOT NULL,
46
+ accountId TEXT NOT NULL,
47
+ providerId TEXT NOT NULL,
48
+ userId TEXT NOT NULL REFERENCES user(id) ON DELETE CASCADE,
49
+ accessToken TEXT,
50
+ refreshToken TEXT,
51
+ idToken TEXT,
52
+ accessTokenExpiresAt INTEGER,
53
+ refreshTokenExpiresAt INTEGER,
54
+ scope TEXT,
55
+ password TEXT,
56
+ createdAt INTEGER NOT NULL,
57
+ updatedAt INTEGER NOT NULL
58
+ )`,
59
+ sql`CREATE TABLE IF NOT EXISTS verification (
60
+ id TEXT PRIMARY KEY NOT NULL,
61
+ identifier TEXT NOT NULL,
62
+ value TEXT NOT NULL,
63
+ expiresAt INTEGER NOT NULL,
64
+ createdAt INTEGER NOT NULL,
65
+ updatedAt INTEGER NOT NULL
66
+ )`,
67
+ ];
68
+
69
+ export class AuthTables implements OnInit {
70
+ constructor(
71
+ private readonly db: SyncDatabase<typeof schema>,
72
+ private readonly logger: Logger,
73
+ ) {}
74
+
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
+ */
80
+ onInit(): void {
81
+ for (const table of TABLES) this.db.run(table);
82
+ this.logger.info(`better-auth tables created (${TABLES.length})`);
83
+ }
84
+ }
@@ -0,0 +1,37 @@
1
+ import { Controller, Get, Public, Roles, UseGuards } from '@dunx/http';
2
+ import { SessionGuard } from '@dunx/auth';
3
+ import { Audit } from './audit.service.js';
4
+
5
+ /**
6
+ * `@UseGuards(SessionGuard)` at class scope rather than global middleware, exactly as
7
+ * `ReportsController` does with its hand-rolled guard: every other route in this app
8
+ * is meant to be reachable without credentials.
9
+ *
10
+ * Nothing here is handed a user. `Audit` reads the caller out of `AuthContext`, which
11
+ * is `AsyncLocalStorage` - so a service two hops from the request sees the principal
12
+ * without it being threaded through a signature.
13
+ */
14
+ @UseGuards(SessionGuard)
15
+ @Controller('profile')
16
+ export class ProfileController {
17
+ constructor(private readonly audit: Audit) {}
18
+
19
+ @Get('/')
20
+ me(): { email: string; roles: readonly string[]; sessionId: string } {
21
+ return this.audit.whoami();
22
+ }
23
+
24
+ /** The class guard reads this and 403s unless the caller holds `admin`. */
25
+ @Roles('admin')
26
+ @Get('/audit')
27
+ entries(): { caller: string; entries: readonly string[] } {
28
+ return this.audit.report();
29
+ }
30
+
31
+ /** The class guard reads this and skips: no session looked up, no rejection. */
32
+ @Public()
33
+ @Get('/anonymous')
34
+ anonymous(): { caller: string | null } {
35
+ return { caller: this.audit.caller() };
36
+ }
37
+ }
@@ -0,0 +1,85 @@
1
+ import {
2
+ Controller,
3
+ Delete,
4
+ Get,
5
+ HttpError,
6
+ HttpStatusCode,
7
+ Put,
8
+ type Input,
9
+ } from '@dunx/http';
10
+ import { z } from 'zod';
11
+ import { Sessions } from './sessions.service.js';
12
+
13
+ const SessionKey = z
14
+ .object({ id: z.string().min(1).max(80) })
15
+ .meta({ id: 'SessionKey', title: 'A session id' });
16
+
17
+ const StoreSession = z
18
+ .object({
19
+ data: z.record(z.string(), z.unknown()),
20
+ ttl: z.coerce.number().int().min(1).max(3600).default(60),
21
+ })
22
+ .meta({ id: 'StoreSession', title: 'Session payload and its lifetime' });
23
+
24
+ const oneSession = { params: SessionKey } as const;
25
+ const putSession = { params: SessionKey, body: StoreSession } as const;
26
+
27
+ /**
28
+ * Every route here answers **503 with the connection error's own message** when
29
+ * no Redis is running, rather than 500 - a cache that is not up is a degraded
30
+ * service, not a bug, and `bun start` must still boot without one.
31
+ */
32
+ @Controller('cache')
33
+ export class CacheController {
34
+ constructor(private readonly sessions: Sessions) {}
35
+
36
+ @Get('/', {})
37
+ async status(): Promise<{ url: string; reachable: boolean; note?: string }> {
38
+ return this.sessions.status();
39
+ }
40
+
41
+ @Get('/:id', oneSession)
42
+ async read(
43
+ input: Input<typeof oneSession>,
44
+ ): Promise<{ id: string; data: unknown; ttl: number }> {
45
+ const found = await this.degrades(() =>
46
+ this.sessions.read(input.params.id),
47
+ );
48
+ if (found === null) {
49
+ throw new HttpError(
50
+ HttpStatusCode.NOT_FOUND,
51
+ `No session "${input.params.id}"`,
52
+ );
53
+ }
54
+ return found;
55
+ }
56
+
57
+ @Put('/:id', putSession)
58
+ store(
59
+ input: Input<typeof putSession>,
60
+ ): Promise<{ id: string; ttl: number; visits: number }> {
61
+ return this.degrades(() =>
62
+ this.sessions.store(input.params.id, input.body.data, input.body.ttl),
63
+ );
64
+ }
65
+
66
+ @Delete('/:id', oneSession)
67
+ remove(input: Input<typeof oneSession>): Promise<{ removed: number }> {
68
+ return this.degrades(() => this.sessions.remove(input.params.id));
69
+ }
70
+
71
+ private async degrades<T>(run: () => Promise<T>): Promise<T> {
72
+ try {
73
+ return await run();
74
+ } catch (error) {
75
+ // Bun raises some of these synchronously, which is why the wrapper catches
76
+ // around the call rather than relying on the promise rejecting.
77
+ if (error instanceof HttpError) throw error;
78
+ if (!this.sessions.isDown(error)) throw error;
79
+ throw new HttpError(
80
+ HttpStatusCode.SERVICE_UNAVAILABLE,
81
+ `Cache unavailable: ${(error as Error).message}`,
82
+ );
83
+ }
84
+ }
85
+ }
@@ -0,0 +1,36 @@
1
+ import { Module } from '@dunx/core';
2
+ import { RedisModule } from '@dunx/infra/redis';
3
+ import { AppConfigService } from '../config.js';
4
+ import { CacheController } from './cache.controller.js';
5
+ import { Sessions } from './sessions.service.js';
6
+
7
+ @Module({
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.
14
+ //
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.
18
+ RedisModule.forRootAsync({
19
+ 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.
23
+ const { url } = config.get('redis');
24
+ return {
25
+ ...(url === undefined ? {} : { url }),
26
+ connectionTimeout: 500,
27
+ maxRetries: 0,
28
+ };
29
+ },
30
+ inject: [AppConfigService] as const,
31
+ }),
32
+ ],
33
+ controllers: [CacheController],
34
+ providers: [Sessions],
35
+ })
36
+ export class CacheModule {}
@@ -0,0 +1,90 @@
1
+ import { Logger } from '@dunx/core';
2
+ import {
3
+ isConnectionError,
4
+ RedisConnection,
5
+ RedisOptions,
6
+ } from '@dunx/infra/redis';
7
+
8
+ export class Sessions {
9
+ // Namespaced by pid so one run cannot read another run's keys.
10
+ readonly #prefix = `dunx-full:${process.pid}`;
11
+
12
+ constructor(
13
+ private readonly redis: RedisConnection,
14
+ private readonly options: RedisOptions,
15
+ private readonly logger: Logger,
16
+ ) {}
17
+
18
+ /** Whether a thrown value means "the cache is down" rather than "the call was wrong". */
19
+ isDown(error: unknown): boolean {
20
+ return isConnectionError(error);
21
+ }
22
+
23
+ async status(): Promise<{ url: string; reachable: boolean; note?: string }> {
24
+ try {
25
+ await this.redis.ping();
26
+ return { url: this.options.url, reachable: true };
27
+ } catch (error) {
28
+ if (!this.isDown(error)) throw error;
29
+ return {
30
+ url: this.options.url,
31
+ reachable: false,
32
+ note: `${(error as Error).message}. A cache that is not running must not fail the app.`,
33
+ };
34
+ }
35
+ }
36
+
37
+ async read(
38
+ id: string,
39
+ ): Promise<{ id: string; data: unknown; ttl: number } | null> {
40
+ const key = this.#key(id);
41
+ const raw = await this.redis.get(key);
42
+ if (raw === null) return null;
43
+ return { id, data: JSON.parse(raw), ttl: await this.redis.ttl(key) };
44
+ }
45
+
46
+ /** SET takes an options object rather than Bun's positional overloads. */
47
+ async store(
48
+ id: string,
49
+ data: Record<string, unknown>,
50
+ ttl: number,
51
+ ): Promise<{ id: string; ttl: number; visits: number }> {
52
+ await this.redis.set(this.#key(id), JSON.stringify(data), { ex: ttl });
53
+ const visits = await this.redis.incr(`${this.#prefix}:visits`);
54
+ return { id, ttl, visits };
55
+ }
56
+
57
+ async remove(id: string): Promise<{ removed: number }> {
58
+ return { removed: await this.redis.del(this.#key(id)) };
59
+ }
60
+
61
+ async demonstrate(): Promise<void> {
62
+ const { redis, logger } = this;
63
+ const session = this.#key('demo');
64
+ const visits = `${this.#prefix}:visits`;
65
+
66
+ try {
67
+ logger.info(`PING ${this.options.url} -> ${await redis.ping()}`);
68
+
69
+ await redis.set(session, JSON.stringify({ user: 'ada' }), { ex: 60 });
70
+ logger.info(
71
+ `SET/GET session -> ${await redis.get(session)}, ` +
72
+ `ttl ${await redis.ttl(session)}s`,
73
+ );
74
+ logger.info(`INCR visits -> ${await redis.incr(visits)}`);
75
+ logger.info(`DEL -> ${await redis.del(session, visits)} keys removed`);
76
+ } catch (error) {
77
+ // Bun raises some of these synchronously, so the wrapper catches around
78
+ // the call and a caller only ever sees a rejection.
79
+ if (!this.isDown(error)) throw error;
80
+ logger.warn(
81
+ `skipping redis at ${this.options.url}: ${(error as Error).message}`,
82
+ );
83
+ logger.info('a cache that is not running must not fail the app');
84
+ }
85
+ }
86
+
87
+ #key(id: string): string {
88
+ return `${this.#prefix}:session:${id}`;
89
+ }
90
+ }