@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,108 @@
1
+ import { Logger } from '@dunx/core';
2
+ import {
3
+ EncodableFormat,
4
+ type EncodedImage,
5
+ ImageFit,
6
+ Images,
7
+ ImagesOptions,
8
+ } from '@dunx/infra/images';
9
+
10
+ /**
11
+ * A 4x4 RGB gradient PNG - the only binary in the example, and small enough to
12
+ * read as a constant. Everything larger is derived from it at runtime by
13
+ * `Bun.Image` itself, so nothing is checked in and nothing is downloaded.
14
+ */
15
+ const SEED_PNG_BASE64 =
16
+ 'iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAIAAAAmkwkpAAAAKElEQVR42g3HMQEAAAzC' +
17
+ 'MEzip3pqki1fktBgWEhKi2X9SEWZn9Hh2DgEahfxpRmu7gAAAABJRU5ErkJggg==';
18
+
19
+ export interface RenderOptions {
20
+ readonly width: number;
21
+ readonly height?: number | undefined;
22
+ readonly fit: ImageFit;
23
+ readonly format: EncodableFormat;
24
+ readonly quality?: number | undefined;
25
+ }
26
+
27
+ export class Thumbnails {
28
+ constructor(
29
+ private readonly images: Images,
30
+ private readonly config: ImagesOptions,
31
+ private readonly logger: Logger,
32
+ ) {}
33
+
34
+ /** The 64x48 source every route below derives from, grown from the 4x4 seed. */
35
+ private async source(): Promise<Uint8Array> {
36
+ const seed = new Uint8Array(Buffer.from(SEED_PNG_BASE64, 'base64'));
37
+ const seeded = await this.images.load(seed);
38
+ return seeded
39
+ .resize(64, 48, { fit: ImageFit.FILL })
40
+ .to(EncodableFormat.PNG)
41
+ .toBytes();
42
+ }
43
+
44
+ async render(options: RenderOptions): Promise<EncodedImage> {
45
+ const pipeline = await this.images.load(await this.source());
46
+ const resized = pipeline.resize(options.width, options.height, {
47
+ fit: options.fit,
48
+ });
49
+ // A quality only means something to a lossy encoder, so it is only passed
50
+ // when one was asked for.
51
+ return options.quality === undefined
52
+ ? resized.to(options.format).encode()
53
+ : resized.to(options.format, { quality: options.quality }).encode();
54
+ }
55
+
56
+ async describe(
57
+ base64: string,
58
+ ): Promise<{ width: number; height: number; format: string }> {
59
+ const bytes = new Uint8Array(Buffer.from(base64, 'base64'));
60
+ const meta = await this.images.metadata(bytes);
61
+ return { width: meta.width, height: meta.height, format: meta.format };
62
+ }
63
+
64
+ async demonstrate(): Promise<void> {
65
+ const { images, logger } = this;
66
+ const seed = new Uint8Array(Buffer.from(SEED_PNG_BASE64, 'base64'));
67
+
68
+ const seeded = await images.load(seed);
69
+ const source = await seeded
70
+ .resize(64, 48, { fit: ImageFit.FILL })
71
+ .to(EncodableFormat.PNG)
72
+ .toBytes();
73
+ logger.info(
74
+ `quality=${this.config.quality}, generated a 64x48 source from the 4x4 seed ` +
75
+ `at runtime: ${source.byteLength} bytes, detected ${images.detect(source)}`,
76
+ );
77
+
78
+ // Content-based: the container comes from magic bytes, never a filename. And
79
+ // this is a header read, not a decode - a truncated file would still answer.
80
+ const meta = await images.metadata(source);
81
+ logger.info(`metadata -> ${meta.width}x${meta.height} ${meta.format}`);
82
+
83
+ const pipeline = await images.load(source);
84
+ const thumb = await pipeline
85
+ .resize(16, 16, { fit: ImageFit.INSIDE })
86
+ .encode();
87
+ logger.info(
88
+ `resize 16x16 inside -> ${thumb.width}x${thumb.height} ${thumb.format}, ` +
89
+ `${thumb.bytes.byteLength} bytes`,
90
+ );
91
+
92
+ const webp = await pipeline
93
+ .resize(32)
94
+ .to(EncodableFormat.WEBP, { quality: 70 })
95
+ .encode();
96
+ logger.info(
97
+ `convert 32px wide -> ${webp.width}x${webp.height} ${webp.mimeType}, ` +
98
+ `${webp.bytes.byteLength} bytes`,
99
+ );
100
+
101
+ // `Bun.Image` mutates and returns `this`; an ImagePipeline returns a new
102
+ // value from every operation, so the two resizes above did not collide.
103
+ const again = await pipeline.sourceMetadata();
104
+ logger.info(
105
+ `the pipeline is immutable: the source is still ${again.width}x${again.height} ${again.format}`,
106
+ );
107
+ }
108
+ }
@@ -0,0 +1,130 @@
1
+ import {
2
+ Controller,
3
+ Delete,
4
+ Get,
5
+ HttpError,
6
+ HttpStatusCode,
7
+ Put,
8
+ type Input,
9
+ } from '@dunx/http';
10
+ import { LocalStorage, PathTraversalError, Storage } from '@dunx/infra/files';
11
+ import { z } from 'zod';
12
+
13
+ /**
14
+ * The key is a query parameter rather than a path segment because keys contain
15
+ * slashes - `reports/q1.csv` is one key, not two segments. Traversal is rejected
16
+ * by `Storage` itself rather than by a pattern here, which is the behaviour worth
17
+ * seeing: try `?key=../../etc/passwd`.
18
+ */
19
+ const FileKey = z
20
+ .object({ key: z.string().min(1).max(200) })
21
+ .meta({ id: 'FileKey', title: 'An object key inside the storage root' });
22
+
23
+ const WriteFile = z
24
+ .object({ content: z.string().max(64 * 1024) })
25
+ .meta({ id: 'WriteFile', title: 'Text to store under the key' });
26
+
27
+ const listFiles = {
28
+ query: z.object({
29
+ prefix: z.string().default(''),
30
+ glob: z.string().default('**/*'),
31
+ }),
32
+ } as const;
33
+ const objectKey = { query: FileKey } as const;
34
+ const writeFile = { query: FileKey, body: WriteFile } as const;
35
+
36
+ /**
37
+ * Injects `Storage`, never `LocalStorage` - swapping a disk for a bucket is one
38
+ * `forRoot` call in storage.module.ts and nothing here changes.
39
+ */
40
+ @Controller('files')
41
+ export class FilesController {
42
+ constructor(private readonly storage: Storage) {}
43
+
44
+ /** `list` is an AsyncIterable so a million objects page rather than accumulate. */
45
+ @Get('/', listFiles)
46
+ async list(
47
+ input: Input<typeof listFiles>,
48
+ ): Promise<{ root: string; keys: readonly string[] }> {
49
+ const keys: string[] = [];
50
+ for await (const entry of this.storage.list({
51
+ prefix: input.query.prefix,
52
+ glob: input.query.glob,
53
+ })) {
54
+ keys.push(entry.key);
55
+ }
56
+ // The contract cannot promise a root - narrowing to the backend is how you
57
+ // reach anything backend-specific.
58
+ const root =
59
+ this.storage instanceof LocalStorage ? this.storage.root : '(remote)';
60
+ return { root, keys: keys.sort() };
61
+ }
62
+
63
+ @Get('/object', objectKey)
64
+ async read(
65
+ input: Input<typeof objectKey>,
66
+ ): Promise<{ key: string; size: number; type: string; content: string }> {
67
+ const { key } = input.query;
68
+ await this.present(key);
69
+ const stat = await this.storage.stat(key);
70
+ return {
71
+ key,
72
+ size: stat.size,
73
+ type: stat.type,
74
+ content: await this.storage.read(key),
75
+ };
76
+ }
77
+
78
+ @Put('/object', writeFile)
79
+ async write(
80
+ input: Input<typeof writeFile>,
81
+ ): Promise<{ key: string; bytes: number }> {
82
+ const { key } = input.query;
83
+ try {
84
+ return { key, bytes: await this.storage.write(key, input.body.content) };
85
+ } catch (error) {
86
+ if (!(error instanceof PathTraversalError)) throw error;
87
+ throw new HttpError(HttpStatusCode.BAD_REQUEST, error.message);
88
+ }
89
+ }
90
+
91
+ @Delete('/object', objectKey)
92
+ async remove(input: Input<typeof objectKey>): Promise<{ deleted: boolean }> {
93
+ const { key } = input.query;
94
+ await this.present(key);
95
+ await this.storage.delete(key);
96
+ return { deleted: true };
97
+ }
98
+
99
+ /**
100
+ * Nothing signs bytes on a local disk, so this refuses with the backend's own
101
+ * message instead of handing back a URL that cannot work.
102
+ */
103
+ @Get('/presign', objectKey)
104
+ async presign(input: Input<typeof objectKey>): Promise<{ url: string }> {
105
+ const { key } = input.query;
106
+ await this.present(key);
107
+ try {
108
+ return { url: this.storage.presign(key) };
109
+ } catch (error) {
110
+ throw new HttpError(
111
+ HttpStatusCode.NOT_IMPLEMENTED,
112
+ (error as Error).message,
113
+ );
114
+ }
115
+ }
116
+
117
+ /**
118
+ * A traversal is a bad request, not a server fault - `Storage` rejects it
119
+ * before any syscall, and without this it would surface as a 500.
120
+ */
121
+ private async present(key: string): Promise<void> {
122
+ try {
123
+ if (await this.storage.exists(key)) return;
124
+ } catch (error) {
125
+ if (!(error instanceof PathTraversalError)) throw error;
126
+ throw new HttpError(HttpStatusCode.BAD_REQUEST, error.message);
127
+ }
128
+ throw new HttpError(HttpStatusCode.NOT_FOUND, `No object "${key}"`);
129
+ }
130
+ }
@@ -0,0 +1,21 @@
1
+ import { Module } from '@dunx/core';
2
+ import { FilesModule, LocalStorageOptions } from '@dunx/infra/files';
3
+ import { FilesController } from './files.controller.js';
4
+ import { Uploads } from './uploads.service.js';
5
+ import { Workspace } from './workspace.js';
6
+
7
+ @Module({
8
+ imports: [
9
+ // The root has to exist before `LocalStorageOptions` names it, and creating
10
+ // it is async - which is the whole reason `forRootAsync` takes a factory that
11
+ // may await and may inject.
12
+ FilesModule.forRootAsync({
13
+ useFactory: async (workspace: Workspace) =>
14
+ new LocalStorageOptions(await workspace.create()),
15
+ inject: [Workspace],
16
+ }),
17
+ ],
18
+ controllers: [FilesController],
19
+ providers: [Workspace, Uploads],
20
+ })
21
+ export class StorageModule {}
@@ -0,0 +1,66 @@
1
+ import { Logger } from '@dunx/core';
2
+ import { LocalStorage, Storage } from '@dunx/infra/files';
3
+
4
+ const REPORT = 'reports/q1.csv';
5
+ const ARCHIVE = 'reports/q2.csv';
6
+
7
+ /**
8
+ * Injects `Storage`, never `LocalStorage` - swapping a disk for a bucket is then
9
+ * one `forRoot` call in storage.module.ts and nothing here changes.
10
+ */
11
+ export class Uploads {
12
+ constructor(
13
+ private readonly storage: Storage,
14
+ private readonly logger: Logger,
15
+ ) {}
16
+
17
+ async demonstrate(): Promise<void> {
18
+ const { storage, logger } = this;
19
+
20
+ // The contract cannot promise a root - narrowing to the backend is how you
21
+ // reach anything backend-specific.
22
+ if (storage instanceof LocalStorage) {
23
+ logger.info(`root ${storage.root} (an OS temp dir, removed on shutdown)`);
24
+ }
25
+
26
+ const bytes = await storage.write(REPORT, 'quarter,amount\nQ1,100\n');
27
+ logger.info(`write ${REPORT} -> ${bytes} bytes`);
28
+ await storage.write(ARCHIVE, 'quarter,amount\nQ2,140\n');
29
+
30
+ logger.info(
31
+ `read ${REPORT} -> ${JSON.stringify(await storage.read(REPORT))}`,
32
+ );
33
+
34
+ const stat = await storage.stat(REPORT);
35
+ logger.info(`stat ${REPORT} -> ${stat.size} bytes, ${stat.type}`);
36
+
37
+ // Bun.Glob, as an AsyncIterable so a million objects page rather than
38
+ // accumulate. Order is the filesystem's, hence the sort.
39
+ const keys: string[] = [];
40
+ for await (const entry of storage.list({
41
+ prefix: 'reports',
42
+ glob: '*.csv',
43
+ })) {
44
+ keys.push(entry.key);
45
+ }
46
+ logger.info(`glob reports/*.csv -> ${JSON.stringify(keys.sort())}`);
47
+
48
+ await storage.delete(ARCHIVE);
49
+ logger.info(`delete ${ARCHIVE} -> exists=${await storage.exists(ARCHIVE)}`);
50
+
51
+ // Rejected before any syscall, not sanitised into something that "works".
52
+ try {
53
+ await storage.read('../../etc/passwd');
54
+ } catch (error) {
55
+ logger.info(`traversal rejected: ${(error as Error).message}`);
56
+ }
57
+
58
+ // Nothing signs bytes on a local disk, so this refuses instead of handing
59
+ // back a URL that cannot work.
60
+ try {
61
+ storage.presign(REPORT);
62
+ } catch (error) {
63
+ logger.info(`presign refused: ${(error as Error).message}`);
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,33 @@
1
+ import { Logger } from '@dunx/core';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import type { OnShutdown } from '@dunx/core';
6
+
7
+ /**
8
+ * Owns a scratch directory under the OS temp dir - never inside the repo - and
9
+ * removes it on shutdown, so two consecutive `bun start` runs cannot see each
10
+ * other's bytes.
11
+ *
12
+ * The storage factory injects this, so it is constructed *before* `Storage`, and
13
+ * reverse-order shutdown therefore removes the directory after everything that
14
+ * writes into it has drained.
15
+ */
16
+ export class Workspace implements OnShutdown {
17
+ #root: string | undefined;
18
+
19
+ constructor(private readonly logger: Logger) {}
20
+
21
+ /** `mkdtemp`, so a concurrent run gets its own directory rather than sharing. */
22
+ async create(): Promise<string> {
23
+ this.#root ??= await mkdtemp(join(tmpdir(), 'dunx-full-'));
24
+ return this.#root;
25
+ }
26
+
27
+ async onShutdown(): Promise<void> {
28
+ if (this.#root === undefined) return;
29
+ await rm(this.#root, { recursive: true, force: true });
30
+ this.logger.info(`workspace removed: ${this.#root}`);
31
+ this.#root = undefined;
32
+ }
33
+ }
@@ -0,0 +1,47 @@
1
+ import {
2
+ Controller,
3
+ Get,
4
+ HttpError,
5
+ HttpStatusCode,
6
+ Post,
7
+ type Input,
8
+ } from '@dunx/http';
9
+ import type { User } from './users.repository.js';
10
+ import { createUser, listUsers, oneUser } from './users.schemas.js';
11
+ import { UsersService } from './users.service.js';
12
+
13
+ @Controller('users')
14
+ export class UsersController {
15
+ constructor(private readonly users: UsersService) {}
16
+
17
+ // `Input<typeof listUsers>` has to be written out - a standard method decorator
18
+ // can check a parameter's type but cannot contextually type an unannotated one.
19
+ // Every field type still comes from the schema, so nothing is declared twice.
20
+ @Get('/', listUsers)
21
+ list(input: Input<typeof listUsers>): Promise<readonly User[]> {
22
+ return this.users.findAll(input.query.limit, input.query.q);
23
+ }
24
+
25
+ @Get('/:id', oneUser)
26
+ async one(input: Input<typeof oneUser>): Promise<User> {
27
+ // Already a number: the params schema coerced it before this ran.
28
+ const user = await this.users.find(input.params.id);
29
+ if (user === null) {
30
+ throw new HttpError(
31
+ HttpStatusCode.NOT_FOUND,
32
+ `No user ${input.params.id}`,
33
+ );
34
+ }
35
+ return user;
36
+ }
37
+
38
+ // No req.json(), no Response.json(), no status - the body arrives validated and
39
+ // typed, and 201 is the POST default.
40
+ @Post('/', createUser)
41
+ create(input: Input<typeof createUser>): Promise<User> {
42
+ return this.users.create(
43
+ input.body.name,
44
+ input.body.tags.map((tag) => tag.label),
45
+ );
46
+ }
47
+ }
@@ -0,0 +1,62 @@
1
+ import { Logger } from '@dunx/core';
2
+ import { z } from 'zod';
3
+ import { CreateUser } from './users.schemas.js';
4
+ import { UsersService } from './users.service.js';
5
+
6
+ const post = (url: string, body: unknown): Promise<Response> =>
7
+ fetch(new URL('api/users', url), {
8
+ method: 'POST',
9
+ headers: { 'content-type': 'application/json' },
10
+ body: JSON.stringify(body),
11
+ });
12
+
13
+ const show = async (response: Response): Promise<string> =>
14
+ `${response.status} ${JSON.stringify(await response.json())}`;
15
+
16
+ export class UsersDemo {
17
+ constructor(
18
+ private readonly users: UsersService,
19
+ private readonly logger: Logger,
20
+ ) {}
21
+
22
+ async demonstrate(url: string): Promise<void> {
23
+ const { logger } = this;
24
+ logger.info(await this.users.summary());
25
+
26
+ const listed = await fetch(new URL('api/users', url));
27
+ logger.info(`GET /api/users -> ${await show(listed)}`);
28
+
29
+ const paged = await fetch(new URL('api/users?limit=1&q=ad', url));
30
+ logger.info(
31
+ `GET /api/users?limit=1&q=ad -> ${await show(paged)} (query coerced by zod)`,
32
+ );
33
+
34
+ const one = await fetch(new URL('api/users/1', url));
35
+ logger.info(
36
+ `GET /api/users/1 -> ${await show(one)} (params.id coerced to a number)`,
37
+ );
38
+
39
+ const created = await post(url, {
40
+ name: 'linus',
41
+ tags: [{ label: 'kernel' }],
42
+ });
43
+ logger.info(`POST /api/users -> ${await show(created)}`);
44
+
45
+ // zod rejects, @dunx/http turns the issues into a 400 body.
46
+ const rejected = await post(url, { name: 42 });
47
+ logger.info(`POST /api/users {"name":42} -> ${await show(rejected)}`);
48
+
49
+ // A nested issue's path is flattened to dots - the same rendering OpenAPI
50
+ // clients expect.
51
+ const nested = await post(url, { name: 'ada', tags: [{ label: '' }] });
52
+ logger.info(
53
+ `POST /api/users {"tags":[{"label":""}]} -> ${await show(nested)}`,
54
+ );
55
+
56
+ // The zod-specific half, and the path OpenAPI generation will take: `id` from
57
+ // .meta() names the $defs entry, `title` lands inline.
58
+ logger.info(
59
+ `z.toJSONSchema(CreateUser) -> ${JSON.stringify(z.toJSONSchema(CreateUser))}`,
60
+ );
61
+ }
62
+ }
@@ -0,0 +1,11 @@
1
+ import { Module } from '@dunx/core';
2
+ import { UsersController } from './users.controller.js';
3
+ import { UsersDemo } from './users.demo.js';
4
+ import { UsersRepository } from './users.repository.js';
5
+ import { UsersService } from './users.service.js';
6
+
7
+ @Module({
8
+ controllers: [UsersController],
9
+ providers: [UsersService, UsersRepository, UsersDemo],
10
+ })
11
+ export class UsersModule {}
@@ -0,0 +1,59 @@
1
+ import { SyncDatabase } from '@dunx/infra/db';
2
+ import { eq, like, sql } from 'drizzle-orm';
3
+ import * as schema from '../database/schema.js';
4
+ import { users, type User } from '../database/schema.js';
5
+
6
+ // The row type comes from the table, so the controller and the service import one
7
+ // definition rather than a hand-written copy of the columns.
8
+ export type { User };
9
+
10
+ export class UsersRepository {
11
+ /**
12
+ * `SyncDatabase` because `DatabaseModule` configured synchronous mode; it is
13
+ * drizzle's `BunSQLiteDatabase` with a name the container can tell apart.
14
+ * `@dunx/transform` records the bare type name - a real runtime class, so a usable
15
+ * token - and ignores the type argument, so the schema types survive injection.
16
+ *
17
+ * Every method below is `async` although bun-sqlite executes synchronously: the
18
+ * HTTP layer awaits them, and moving this table to the pooled backend then costs
19
+ * no signature change. `Ledger.transferSync` is what refusing that trade looks
20
+ * like.
21
+ */
22
+ constructor(private readonly db: SyncDatabase<typeof schema>) {}
23
+
24
+ async migrate(): Promise<void> {
25
+ this.db.run(sql`CREATE TABLE IF NOT EXISTS users (
26
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
27
+ name TEXT NOT NULL UNIQUE
28
+ )`);
29
+ }
30
+
31
+ /** One statement for every name; `name` is UNIQUE, so a repeat boot is a no-op. */
32
+ async seed(names: readonly string[]): Promise<void> {
33
+ this.db
34
+ .insert(users)
35
+ .values(names.map((name) => ({ name })))
36
+ .onConflictDoNothing()
37
+ .run();
38
+ }
39
+
40
+ async findAll(limit: number, q?: string): Promise<readonly User[]> {
41
+ return this.db
42
+ .select()
43
+ .from(users)
44
+ .where(like(users.name, q === undefined ? '%' : `%${q}%`))
45
+ .orderBy(users.id)
46
+ .limit(limit)
47
+ .all();
48
+ }
49
+
50
+ /** `.get()` is `undefined` for no row; the controller's 404 turns on `null`. */
51
+ async find(id: number): Promise<User | null> {
52
+ return this.db.select().from(users).where(eq(users.id, id)).get() ?? null;
53
+ }
54
+
55
+ /** `.returning()`, so the id is the one the database wrote. */
56
+ async create(name: string): Promise<User> {
57
+ return this.db.insert(users).values({ name }).returning().get();
58
+ }
59
+ }
@@ -0,0 +1,68 @@
1
+ import type { RouteSchemas } from '@dunx/http';
2
+ import { z } from 'zod';
3
+
4
+ /**
5
+ * Real zod, dropped straight into a route's options: `z.object()` already carries
6
+ * `~standard` (vendor `zod`, version 1), which is the entire contract
7
+ * `@dunx/http` validates against - so nothing adapts anything, and the framework
8
+ * still depends on no validator.
9
+ *
10
+ * `.meta({ id })` names the definition zod emits under `$defs`, which is the slot
11
+ * OpenAPI calls `components/schemas`. `.meta({ title })` lands inline. See
12
+ * `UsersDemo` for the generated document.
13
+ */
14
+ export const Tag = z
15
+ .object({ label: z.string().min(1) })
16
+ .meta({ id: 'Tag', title: 'A label attached to a user' });
17
+
18
+ export const CreateUser = z
19
+ .object({
20
+ name: z.string().min(1).max(40),
21
+ tags: z.array(Tag).default([]),
22
+ })
23
+ .meta({ id: 'CreateUser', title: 'Create a user' });
24
+
25
+ /** Path params arrive as strings; `z.coerce` is where `:id` becomes a number. */
26
+ export const UserIndex = z
27
+ .object({ id: z.coerce.number().int().min(1) })
28
+ .meta({ id: 'UserIndex', title: 'A user id in the path' });
29
+
30
+ export const ListUsers = z
31
+ .object({
32
+ q: z.string().min(1).optional(),
33
+ limit: z.coerce.number().int().min(1).max(50).default(10),
34
+ })
35
+ .meta({ id: 'ListUsers', title: 'Filter and page the user list' });
36
+
37
+ /**
38
+ * The response side. Same Standard Schema contract as a request, so it hoists into
39
+ * `components/schemas` the same way - but it is **never validated**: it documents
40
+ * what comes back, and the handler's return type is what checks it.
41
+ */
42
+ export const User = z
43
+ .object({
44
+ id: z.number().int(),
45
+ name: z.string(),
46
+ tags: z.array(z.string()),
47
+ })
48
+ .meta({ id: 'User', title: 'A stored user' });
49
+
50
+ export const NotFound = z
51
+ .object({ error: z.string(), status: z.literal(404) })
52
+ .meta({ id: 'NotFound', title: 'Nothing at that id' });
53
+
54
+ // Declaring a schema is what makes the matching `input` field exist, get parsed
55
+ // and get validated. `satisfies` keeps the literal types `Input<>` reads.
56
+ export const listUsers = {
57
+ query: ListUsers,
58
+ response: { 200: z.array(User) },
59
+ } as const satisfies RouteSchemas;
60
+ export const oneUser = {
61
+ params: UserIndex,
62
+ response: { 200: User, 404: NotFound },
63
+ } as const satisfies RouteSchemas;
64
+ // No status: POST defaults to 201, every other verb to 200.
65
+ export const createUser = {
66
+ body: CreateUser,
67
+ response: { 201: User },
68
+ } as const satisfies RouteSchemas;
@@ -0,0 +1,46 @@
1
+ import { Logger } from '@dunx/core';
2
+ import type { OnInit, OnShutdown } from '@dunx/core';
3
+ import { AppConfigService } from '../config.js';
4
+ import { UsersRepository, type User } from './users.repository.js';
5
+
6
+ export class UsersService implements OnInit, OnShutdown {
7
+ // `ConfigService<AppConfig>` injects because the transform records the bare
8
+ // name of a generic annotation - the type argument costs nothing at runtime.
9
+ constructor(
10
+ private readonly repository: UsersRepository,
11
+ private readonly logger: Logger,
12
+ private readonly config: AppConfigService,
13
+ ) {}
14
+
15
+ /** `onInit` is awaited, so the schema exists before the first request arrives. */
16
+ async onInit(): Promise<void> {
17
+ await this.repository.migrate();
18
+ await this.repository.seed(this.config.get('seedUsers'));
19
+ this.logger.info(`${this.config.get('appName')}: users ready`);
20
+ }
21
+
22
+ onShutdown(): void {
23
+ this.logger.info('users draining');
24
+ }
25
+
26
+ findAll(limit: number, q?: string): Promise<readonly User[]> {
27
+ return this.repository.findAll(limit, q);
28
+ }
29
+
30
+ find(id: number): Promise<User | null> {
31
+ return this.repository.find(id);
32
+ }
33
+
34
+ create(name: string, tags: readonly string[]): Promise<User> {
35
+ const labels = tags.length === 0 ? '' : ` [${tags.join(', ')}]`;
36
+ this.logger.info(
37
+ `${this.config.get('appName')}: creating ${name}${labels}`,
38
+ );
39
+ return this.repository.create(name);
40
+ }
41
+
42
+ async summary(): Promise<string> {
43
+ const users = await this.repository.findAll(50);
44
+ return `${users.length} users: ${users.map((user) => user.name).join(', ')}`;
45
+ }
46
+ }
@@ -0,0 +1,7 @@
1
+ # The one line that makes constructor injection work. The compiler plugin records
2
+ # each class's constructor parameter types so the container can resolve them.
3
+ # Without it, providers are built with no arguments and boot fails saying so.
4
+ preload = ["@dunx/transform/preload"]
5
+
6
+ [test]
7
+ preload = ["@dunx/transform/preload"]