lakutata 3.0.0 → 3.1.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 (117) hide show
  1. package/README.md +50 -30
  2. package/README.zh-CN.md +130 -0
  3. package/dist/cjs/exports/QueueComponent.d.ts +1 -0
  4. package/dist/cjs/exports/QueueComponent.js +20 -0
  5. package/dist/esm/exports/QueueComponent.js +1 -0
  6. package/dist/types/exports/QueueComponent.d.ts +1 -0
  7. package/doc/en/Application.md +107 -0
  8. package/doc/en/CLI.md +274 -0
  9. package/doc/en/Cache.md +379 -0
  10. package/doc/en/Command.md +110 -0
  11. package/doc/en/ContainerdIntegration.md +17 -16
  12. package/doc/en/Controllers.md +256 -0
  13. package/doc/en/DBus.md +291 -0
  14. package/doc/en/DTOValidation.md +72 -43
  15. package/doc/en/Database.md +188 -0
  16. package/doc/en/DatabaseDrivers.md +287 -0
  17. package/doc/en/Decorator.md +52 -40
  18. package/doc/en/DockerIntegration.md +7 -6
  19. package/doc/en/Entities.md +240 -0
  20. package/doc/en/Entrypoint.md +216 -0
  21. package/doc/en/Exceptions.md +174 -0
  22. package/doc/en/GRPC.md +251 -0
  23. package/doc/en/GettingStarted.md +165 -0
  24. package/doc/en/HTTP.md +507 -0
  25. package/doc/en/HelperFunctions.md +11 -11
  26. package/doc/en/I18n.md +119 -115
  27. package/doc/en/Injection.md +144 -0
  28. package/doc/en/Installers.md +280 -0
  29. package/doc/en/Logger.md +206 -0
  30. package/doc/en/MCP.md +293 -0
  31. package/doc/en/Middlewares.md +213 -0
  32. package/doc/en/Migrations.md +202 -0
  33. package/doc/en/MongoEmbedded.md +149 -0
  34. package/doc/en/Monitor.md +164 -0
  35. package/doc/en/Mutex.md +27 -25
  36. package/doc/en/NATS.md +246 -0
  37. package/doc/en/NativeAddons.md +212 -0
  38. package/doc/en/Objects.md +143 -0
  39. package/doc/en/Overview.md +62 -85
  40. package/doc/en/Packaging.md +193 -0
  41. package/doc/en/ProjectStructure.md +124 -0
  42. package/doc/en/Queue.md +269 -0
  43. package/doc/en/Schedules.md +217 -0
  44. package/doc/en/Security.md +10 -9
  45. package/doc/en/ServiceCalls.md +504 -0
  46. package/doc/en/Time.md +19 -20
  47. package/doc/en/UpgradingFrom1.md +435 -0
  48. package/doc/en/UpgradingFrom2.md +315 -0
  49. package/doc/en/Workspaces.md +189 -0
  50. package/doc/en/examples/BackgroundJobs.md +677 -0
  51. package/doc/en/examples/CommandLineTool.md +664 -0
  52. package/doc/en/examples/DesktopApp.md +809 -0
  53. package/doc/en/examples/DeviceService.md +607 -0
  54. package/doc/en/examples/MCPServer.md +653 -0
  55. package/doc/en/examples/README.md +16 -0
  56. package/doc/en/examples/RestAPI.md +490 -0
  57. package/doc/en/examples/ServiceCalls.md +829 -0
  58. package/doc/en/examples/WebApp.md +568 -0
  59. package/doc/zh/Application.md +75 -0
  60. package/doc/zh/CLI.md +250 -0
  61. package/doc/zh/Cache.md +328 -0
  62. package/doc/zh/Command.md +78 -0
  63. package/doc/zh/ContainerdIntegration.md +9 -9
  64. package/doc/zh/Controllers.md +228 -0
  65. package/doc/zh/DBus.md +257 -0
  66. package/doc/zh/DTOValidation.md +49 -19
  67. package/doc/zh/Database.md +175 -0
  68. package/doc/zh/DatabaseDrivers.md +261 -0
  69. package/doc/zh/Decorator.md +25 -18
  70. package/doc/zh/DockerIntegration.md +6 -6
  71. package/doc/zh/Entities.md +224 -0
  72. package/doc/zh/Entrypoint.md +193 -0
  73. package/doc/zh/Exceptions.md +158 -0
  74. package/doc/zh/GRPC.md +226 -0
  75. package/doc/zh/GettingStarted.md +148 -0
  76. package/doc/zh/HTTP.md +445 -0
  77. package/doc/zh/HelperFunctions.md +2 -2
  78. package/doc/zh/I18n.md +26 -26
  79. package/doc/zh/Injection.md +129 -0
  80. package/doc/zh/Installers.md +232 -0
  81. package/doc/zh/Logger.md +188 -0
  82. package/doc/zh/MCP.md +265 -0
  83. package/doc/zh/Middlewares.md +198 -0
  84. package/doc/zh/Migrations.md +178 -0
  85. package/doc/zh/MongoEmbedded.md +118 -0
  86. package/doc/zh/Monitor.md +145 -0
  87. package/doc/zh/Mutex.md +10 -10
  88. package/doc/zh/NATS.md +207 -0
  89. package/doc/zh/NativeAddons.md +180 -0
  90. package/doc/zh/Objects.md +119 -0
  91. package/doc/zh/Overview.md +46 -71
  92. package/doc/zh/Packaging.md +154 -0
  93. package/doc/zh/ProjectStructure.md +102 -0
  94. package/doc/zh/Queue.md +214 -0
  95. package/doc/zh/Schedules.md +193 -0
  96. package/doc/zh/Security.md +6 -6
  97. package/doc/zh/ServiceCalls.md +459 -0
  98. package/doc/zh/Time.md +2 -2
  99. package/doc/zh/UpgradingFrom1.md +347 -0
  100. package/doc/zh/UpgradingFrom2.md +230 -0
  101. package/doc/zh/Workspaces.md +150 -0
  102. package/doc/zh/examples/BackgroundJobs.md +618 -0
  103. package/doc/zh/examples/CommandLineTool.md +618 -0
  104. package/doc/zh/examples/DesktopApp.md +748 -0
  105. package/doc/zh/examples/DeviceService.md +546 -0
  106. package/doc/zh/examples/MCPServer.md +604 -0
  107. package/doc/zh/examples/README.md +14 -0
  108. package/doc/zh/examples/RestAPI.md +458 -0
  109. package/doc/zh/examples/ServiceCalls.md +765 -0
  110. package/doc/zh/examples/WebApp.md +520 -0
  111. package/package.json +46 -17
  112. package/doc/en/ActionPattern.md +0 -408
  113. package/doc/en/Core.md +0 -312
  114. package/doc/en/ORMIntegration.md +0 -341
  115. package/doc/zh/ActionPattern.md +0 -334
  116. package/doc/zh/Core.md +0 -256
  117. package/doc/zh/ORMIntegration.md +0 -319
@@ -0,0 +1,677 @@
1
+ # Background jobs
2
+
3
+ This example builds a service that makes reports in the background: a request queues a report and gets its id at once,
4
+ a queue action builds the report, and the client asks for its state until it is done. A scheduled job deletes the old
5
+ reports, in one instance of the service at a time. It starts from the `service` template and shows the queue component
6
+ and its actions, the retries and the dead letter queue, a schedule, a lock shared by the instances, and the tests. No
7
+ server is needed: the queue is in memory, and the reports and the locks are files.
8
+
9
+ | Action | Name | What it does |
10
+ |---|---|---|
11
+ | `POST` | `/reports` | Queues the report of a month, `{"month": "2026-09"}`, and answers with its id |
12
+ | `GET` | `/reports/:id` | Gives the state of a report (`queued`, `running`, `done`, `failed`) and its result |
13
+ | Queue | `report.build` | Builds a report: 2 at a time, 3 attempts |
14
+ | Queue | `report.build.dead` | Marks the reports that used up their attempts as failed |
15
+ | Schedule | `*/5 * * * *` | Deletes the reports finished more than a day ago, in one instance |
16
+
17
+ ## 1. Create the project
18
+
19
+ ```bash
20
+ npx @lakutata/cli create --name reports --template service --yes
21
+ cd reports
22
+ rm packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts
23
+ echo 'data/' >> .gitignore
24
+ ```
25
+
26
+ The project is a workspace whose service is `packages/app` ([ProjectStructure](../ProjectStructure.md)). The queues
27
+ (`lakutata/com/queue`) and the locks (`lakutata/com/mutex`) come with the `lakutata` package, and their memory and file
28
+ drivers need no other package, so nothing is installed. The example controller of the template is removed, and the
29
+ `data` directory, which holds the reports and the locks, is kept out of git.
30
+
31
+ ## 2. The input
32
+
33
+ **`packages/app/src/dtos/ReportDTO.ts`**
34
+
35
+ ```typescript
36
+ import {DTO} from 'lakutata'
37
+ import {Expect} from 'lakutata/decorator/dto'
38
+
39
+ /**
40
+ * A new report: the sales of a month
41
+ */
42
+ export class CreateReportDTO extends DTO {
43
+ @Expect(DTO.String().pattern(/^\d{4}-(0[1-9]|1[0-2])$/, 'YYYY-MM').required())
44
+ public month: string
45
+ }
46
+
47
+ /**
48
+ * A report by its id
49
+ */
50
+ export class ReportIdDTO extends DTO {
51
+ @Expect(DTO.String().uuid().required())
52
+ public id: string
53
+ }
54
+
55
+ /**
56
+ * The message of a job: the report to build
57
+ */
58
+ export class BuildReportDTO extends CreateReportDTO {
59
+ @Expect(DTO.String().uuid().required())
60
+ public id: string
61
+ }
62
+ ```
63
+
64
+ - `CreateReportDTO` is the body of `POST /reports`. The second argument of `pattern()` names the pattern in the error
65
+ message: `"month" with value "2026-13" fails to match the YYYY-MM pattern`.
66
+ - `BuildReportDTO` is the message that the queue carries: the id of the report and its month. A queue action validates
67
+ its message with its DTO, as any action validates its input ([DTOValidation](../DTOValidation.md)).
68
+
69
+ ## 3. The reports
70
+
71
+ **`packages/app/src/components/Reports.ts`**
72
+
73
+ ```typescript
74
+ import {randomUUID} from 'node:crypto'
75
+ import {mkdir, readdir, readFile, rename, rm, writeFile} from 'node:fs/promises'
76
+ import path from 'node:path'
77
+ import {Component, DTO} from 'lakutata'
78
+ import {Configurable} from 'lakutata/decorator/di'
79
+ import {type SalesSummary} from './Sales.js'
80
+ import {ReportNotFoundException} from '../exceptions/ReportNotFoundException.js'
81
+
82
+ /**
83
+ * A report, from its request to its result
84
+ */
85
+ export interface Report {
86
+ id: string
87
+ month: string
88
+ state: 'queued' | 'running' | 'done' | 'failed'
89
+ attempts: number
90
+ createdAt: string
91
+ finishedAt?: string
92
+ result?: SalesSummary
93
+ error?: string
94
+ }
95
+
96
+ /**
97
+ * The reports: a JSON file each, in a directory that the instances of the service on a machine share
98
+ */
99
+ export class Reports extends Component {
100
+
101
+ /**
102
+ * The directory of the reports
103
+ */
104
+ @Configurable(DTO.String().required())
105
+ protected readonly directory: string
106
+
107
+ /**
108
+ * How long a finished report is kept, in milliseconds
109
+ */
110
+ @Configurable(DTO.Number().positive().required())
111
+ protected readonly keep: number
112
+
113
+ /**
114
+ * Create the directory when it is missing
115
+ */
116
+ protected async init(): Promise<void> {
117
+ await mkdir(this.directory, {recursive: true})
118
+ }
119
+
120
+ /**
121
+ * The file of a report
122
+ * @param id
123
+ */
124
+ protected file(id: string): string {
125
+ return path.join(this.directory, `${id}.json`)
126
+ }
127
+
128
+ /**
129
+ * Write a report to a temporary file, then rename the file, so that a reader never gets half a report
130
+ * @param report
131
+ */
132
+ protected async save(report: Report): Promise<Report> {
133
+ const temporary: string = `${this.file(report.id)}.${process.pid}.tmp`
134
+ await writeFile(temporary, JSON.stringify(report))
135
+ await rename(temporary, this.file(report.id))
136
+ return report
137
+ }
138
+
139
+ /**
140
+ * A new report, queued
141
+ * @param month
142
+ */
143
+ public async create(month: string): Promise<Report> {
144
+ return await this.save({id: randomUUID(), month: month, state: 'queued', attempts: 0, createdAt: new Date().toISOString()})
145
+ }
146
+
147
+ /**
148
+ * A report, or a 404 error
149
+ * @param id
150
+ */
151
+ public async get(id: string): Promise<Report> {
152
+ try {
153
+ return JSON.parse(await readFile(this.file(id), 'utf8')) as Report
154
+ } catch (error) {
155
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw new ReportNotFoundException(`The report ${id} does not exist`)
156
+ throw error
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Change a report
162
+ * @param id
163
+ * @param changes
164
+ */
165
+ public async update(id: string, changes: Partial<Report>): Promise<Report> {
166
+ return await this.save({...await this.get(id), ...changes})
167
+ }
168
+
169
+ /**
170
+ * Delete the reports that finished longer ago than the time they are kept
171
+ * @param now the time of the cleaning
172
+ * @returns the number of reports deleted
173
+ */
174
+ public async clean(now: Date = new Date()): Promise<number> {
175
+ let deleted: number = 0
176
+ for (const name of await readdir(this.directory)) {
177
+ if (!name.endsWith('.json')) continue
178
+ const report: Report = await this.get(path.basename(name, '.json'))
179
+ if (report.finishedAt && now.getTime() - Date.parse(report.finishedAt) > this.keep) {
180
+ await rm(this.file(report.id), {force: true})
181
+ deleted++
182
+ }
183
+ }
184
+ return deleted
185
+ }
186
+ }
187
+ ```
188
+
189
+ **`packages/app/src/exceptions/ReportNotFoundException.ts`**
190
+
191
+ ```typescript
192
+ import {Exception} from 'lakutata'
193
+
194
+ /**
195
+ * The report of the request does not exist
196
+ */
197
+ export class ReportNotFoundException extends Exception {
198
+ public errno: string = 'E_REPORT_NOT_FOUND'
199
+
200
+ public readonly statusCode: number = 404
201
+ }
202
+ ```
203
+
204
+ A report is `queued`, then `running`, and ends `done` with its result or `failed` with its error. The `Reports`
205
+ component keeps each report in a JSON file:
206
+
207
+ - A component is created once and shared by the whole service: the HTTP actions, the queue actions and the schedule
208
+ use the same instance ([Objects](../Objects.md)). Its `@Configurable` properties are its options, given in
209
+ `Config.ts`, and its `init()` creates the directory.
210
+ - A report is written to a temporary file, then renamed, so a request never reads half a report.
211
+ - `clean()` deletes the reports that finished more than `keep` ago; it is what the schedule runs. It takes the current
212
+ time as an argument, so that the test can run it as if it were tomorrow.
213
+ - The instances of the service on one machine share the directory. In a service with a database, the reports would be
214
+ a table, behind the same methods.
215
+
216
+ ## 4. The sales
217
+
218
+ **`packages/app/src/components/Sales.ts`**
219
+
220
+ ```typescript
221
+ import {setTimeout} from 'node:timers/promises'
222
+ import {Component} from 'lakutata'
223
+
224
+ /**
225
+ * The sales of a month
226
+ */
227
+ export interface SalesSummary {
228
+ orders: number
229
+ revenue: number
230
+ }
231
+
232
+ /**
233
+ * The sales of the shop. Made up in this example; in a real service, queries to its database, the slow part of a report
234
+ */
235
+ export class Sales extends Component {
236
+
237
+ /**
238
+ * The orders and the revenue of a month
239
+ * @param month 2026-09
240
+ * @param signal aborted when the job is stopped: at its time limit, or when the service stops
241
+ */
242
+ public async summarize(month: string, signal?: AbortSignal): Promise<SalesSummary> {
243
+ //The slow part: half a second here
244
+ await setTimeout(500, undefined, {signal: signal})
245
+ //Made-up figures, always the same for a given month
246
+ const orders: number = Number(month.replace('-', '')) % 500 + 100
247
+ return {orders: orders, revenue: orders * 37.5}
248
+ }
249
+ }
250
+ ```
251
+
252
+ A report counts the orders of a month and their revenue. Here the figures are made up, after a wait of half a second
253
+ that stands for the slow queries of a real service. `summarize()` takes the job's signal, so that the wait stops when
254
+ the job is aborted. Because `Sales` is a separate component, the tests can replace its `summarize()` to make a job fail.
255
+
256
+ ## 5. The controller
257
+
258
+ **`packages/app/src/controllers/ReportController.ts`**
259
+
260
+ ```typescript
261
+ import {Controller, Result, type StatusResult} from 'lakutata/com/entrypoint'
262
+ import {type Logger} from 'lakutata/com/logger'
263
+ import {type Queue, QueueAction, type QueueContext, type QueueDeadLetter} from 'lakutata/com/queue'
264
+ import {GET, POST, Prefix, Schedule} from 'lakutata/decorator/ctrl'
265
+ import {Inject} from 'lakutata/decorator/di'
266
+ import {type Report, type Reports} from '../components/Reports.js'
267
+ import {type Sales, type SalesSummary} from '../components/Sales.js'
268
+ import {BuildReportDTO, CreateReportDTO, ReportIdDTO} from '../dtos/ReportDTO.js'
269
+
270
+ @Prefix({route: '/reports'})
271
+ export class ReportController extends Controller {
272
+
273
+ /**
274
+ * The queues of the service (the component "queue" of its options)
275
+ */
276
+ @Inject('queue')
277
+ protected readonly queue: Queue
278
+
279
+ /**
280
+ * The reports (the component "reports")
281
+ */
282
+ @Inject('reports')
283
+ protected readonly reports: Reports
284
+
285
+ /**
286
+ * The sales of the shop (the component "sales")
287
+ */
288
+ @Inject('sales')
289
+ protected readonly sales: Sales
290
+
291
+ /**
292
+ * The log of the service
293
+ */
294
+ @Inject('log')
295
+ protected readonly log: Logger
296
+
297
+ /**
298
+ * POST /reports {"month": "2026-09"}: the report is queued, and its id returned at once
299
+ * @param input
300
+ */
301
+ @POST('/', CreateReportDTO)
302
+ public async create(input: CreateReportDTO): Promise<StatusResult> {
303
+ const report: Report = await this.reports.create(input.month)
304
+ await this.queue.add('report.build', {id: report.id, month: report.month}, {jobId: report.id})
305
+ return Result.status(202, report, {Location: `/reports/${report.id}`})
306
+ }
307
+
308
+ /**
309
+ * GET /reports/:id: its state, and its result once done
310
+ * @param input
311
+ */
312
+ @GET('/:id', ReportIdDTO)
313
+ public async get(input: ReportIdDTO): Promise<Report> {
314
+ return await this.reports.get(input.id)
315
+ }
316
+
317
+ /**
318
+ * Builds the reports of the queue report.build: 2 at a time, 3 attempts 1 s then 2 s apart, 1 minute each
319
+ * @param input
320
+ */
321
+ @QueueAction('report.build', BuildReportDTO, {concurrency: 2, attempts: 3, backoff: {type: 'exponential', delay: '1s'}, timeout: '1m'})
322
+ public async build(input: BuildReportDTO): Promise<SalesSummary> {
323
+ const context: QueueContext = this.context as QueueContext
324
+ await this.reports.update(input.id, {state: 'running', attempts: context.attempt})
325
+ try {
326
+ const result: SalesSummary = await this.sales.summarize(input.month, this.signal)
327
+ await this.reports.update(input.id, {state: 'done', result: result, error: undefined, finishedAt: new Date().toISOString()})
328
+ return result
329
+ } catch (error) {
330
+ //Back in the queue: delivered again after the backoff while attempts remain, then sent to report.build.dead
331
+ await this.reports.update(input.id, {state: 'queued', error: (error as Error).message})
332
+ throw error
333
+ }
334
+ }
335
+
336
+ /**
337
+ * The jobs of report.build that used up their attempts
338
+ * @param input
339
+ */
340
+ @QueueAction('report.build.dead')
341
+ public async failed(input: QueueDeadLetter): Promise<void> {
342
+ const job: BuildReportDTO = input.input as BuildReportDTO
343
+ await this.reports.update(job.id, {state: 'failed', error: input.error.message, finishedAt: input.failedAt})
344
+ }
345
+
346
+ /**
347
+ * Every 5 minutes, in one instance at a time: delete the old reports
348
+ */
349
+ @Schedule({cron: '*/5 * * * *', exclusive: true})
350
+ public async clean(): Promise<void> {
351
+ const deleted: number = await this.reports.clean()
352
+ this.log.info('%d old reports deleted', deleted)
353
+ }
354
+ }
355
+ ```
356
+
357
+ One controller holds the actions of three transports: HTTP, the queue and the schedule. Each request, message or run
358
+ gets a new instance of the controller, with the components injected ([Controllers](../Controllers.md)).
359
+
360
+ - `create()` saves the report, adds a message to the queue `report.build` with `queue.add()`, and answers without
361
+ waiting for the build: `Result.status()` sends the status 202 (Accepted), the report, and a `Location` header that
362
+ points to it ([HTTP](../HTTP.md)). The `jobId` of the message is the id of the report.
363
+ - `@QueueAction` makes `build()` the consumer of the queue `report.build` ([Queue](../Queue.md)). It processes 2
364
+ messages at a time (`concurrency`), each for at most 1 minute (`timeout`: after that, `this.signal` is aborted and
365
+ the message is retried). Its context, a `QueueContext`, gives the number of the current `attempt` (1 on the first
366
+ delivery) and the `attempts` allowed.
367
+ - A message is acknowledged when its action returns, and the value the action returns becomes the result of the
368
+ message, which the queue keeps. When the action throws, `build()` puts the report back in the `queued` state with
369
+ the error, and the message is delivered again after the `backoff` (1 s, then 2 s), up to 3 attempts. After the third
370
+ failed attempt, it goes to the dead letter queue `report.build.dead`, a queue like the others: `failed()` consumes
371
+ it and marks the report as failed.
372
+ - A message is delivered at least once: after a crash, a server-backed driver delivers it again, and `build()` builds
373
+ the same report a second time, which does no harm.
374
+ - `@Schedule` runs `clean()` at the times of the cron expression, every 5 minutes ([Schedules](../Schedules.md)). With
375
+ `exclusive: true`, each run first tries to get the lock `schedule:ReportController:clean` from the `mutex` component;
376
+ the instances that do not get it skip the run ([Mutex](../Mutex.md)).
377
+
378
+ ## 6. The options of the service
379
+
380
+ **`packages/app/src/config/Config.ts`**
381
+
382
+ ```typescript
383
+ import path from 'node:path'
384
+ import type {ApplicationOptions} from 'lakutata'
385
+ import {buildEntrypoints, scheduleEntrypoint} from 'lakutata/com/entrypoint'
386
+ import {Mutex} from 'lakutata/com/mutex'
387
+ import {buildQueueOptions, queueEntrypoint} from 'lakutata/com/queue'
388
+ import {expressHTTPEntrypoint, jsonEnvelope} from 'lakutata/adapter/express'
389
+ import compression from 'compression'
390
+ import {authenticate} from '../middlewares/Authenticate.js'
391
+ import {HealthController} from '../controllers/HealthController.js'
392
+ import {ReportController} from '../controllers/ReportController.js'
393
+ import {Reports} from '../components/Reports.js'
394
+ import {Sales} from '../components/Sales.js'
395
+
396
+ /**
397
+ * The options of the service
398
+ * @param port the port of the HTTP server, the PORT environment variable or 8080 by default
399
+ * @param data the directory of the reports and of the locks, the DATA environment variable or data by default
400
+ */
401
+ export function config(port: number = Number(process.env.PORT ?? 8080), data: string = process.env.DATA ?? 'data'): ApplicationOptions {
402
+ return {
403
+ id: 'reports',
404
+ name: 'reports',
405
+ mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
406
+ components: {
407
+ //The messages are kept in the memory of the process, and their results for an hour
408
+ queue: buildQueueOptions({results: {keep: '1h'}}),
409
+ //The locks: files that the instances of the service on a machine share
410
+ mutex: {class: Mutex, driver: {type: 'file', directory: path.join(data, 'locks')}},
411
+ //The finished reports are kept for a day
412
+ reports: {class: Reports, directory: path.join(data, 'reports'), keep: 24 * 60 * 60 * 1000},
413
+ sales: {class: Sales},
414
+ entrypoint: buildEntrypoints({
415
+ controllers: [HealthController, ReportController],
416
+ http: expressHTTPEntrypoint({
417
+ port: port,
418
+ //The results and the errors are {errno, errMsg, data}; the errors keep their status (400, 404…)
419
+ respond: jsonEnvelope(),
420
+ body: {limit: '100mb'},
421
+ server: {keepAliveTimeout: 60000, headersTimeout: 65000},
422
+ middlewares: [authenticate],
423
+ before: (app): void => void app.use(compression())
424
+ }),
425
+ //Delivers the messages of the queues to the @QueueAction actions
426
+ queue: queueEntrypoint('queue'),
427
+ //Runs the @Schedule actions
428
+ schedule: scheduleEntrypoint()
429
+ })
430
+ },
431
+ bootstrap: ['entrypoint']
432
+ }
433
+ }
434
+ ```
435
+
436
+ This is the `Config.ts` of the template, with:
437
+
438
+ - the `queue` component, made by `buildQueueOptions()`: without a `driver`, the messages are kept in the memory of the
439
+ process. The queue keeps the results of the messages for an hour (a day by default): the test reads one of them,
440
+ while the service itself reads the reports from their files;
441
+ - the `mutex` component, with the `file` driver: a lock is a file in `data/locks`, shared by the processes on the
442
+ machine that use this directory;
443
+ - the `reports` and `sales` components of the example, with their options;
444
+ - the `ReportController` instead of the example controller;
445
+ - two more entrypoint adapters: `queueEntrypoint('queue')` delivers the messages of the `queue` component to the
446
+ `@QueueAction` actions, and `scheduleEntrypoint()` runs the `@Schedule` actions ([Entrypoint](../Entrypoint.md)).
447
+ Without its adapter, a decorator has no effect;
448
+ - `jsonEnvelope()` without `errorStatus`, so that the errors keep their status (400, 404), as REST clients expect.
449
+
450
+ `bootstrap: ['entrypoint']` creates the entrypoint at launch, and with it the queue: the actions start consuming once
451
+ the service has launched. The template's middleware `Authenticate.ts` stays: it is a middleware of the HTTP adapter, so
452
+ the queue messages and the scheduled runs do not go through it.
453
+
454
+ ## 7. Run it
455
+
456
+ Run two instances of the service on this machine; they share the `data` directory:
457
+
458
+ ```bash
459
+ npm run dev
460
+ ```
461
+
462
+ In a second terminal, once the first instance has started:
463
+
464
+ ```bash
465
+ PORT=8081 npm start
466
+ ```
467
+
468
+ In a third terminal, queue a report on the first instance and read it on the second one:
469
+
470
+ ```bash
471
+ curl -i -X POST localhost:8080/reports -H 'Content-Type: application/json' -d '{"month": "2026-09"}'
472
+ # HTTP/1.1 202 Accepted
473
+ # Location: /reports/eadbb7c5-7202-4aca-b2b6-daeeb23ad9f9
474
+ # {"errno":null,"errMsg":null,"data":{"id":"eadbb7c5-7202-4aca-b2b6-daeeb23ad9f9","month":"2026-09","state":"queued","attempts":0,"createdAt":"2026-10-17T15:00:46.797Z"}}
475
+
476
+ curl localhost:8081/reports/eadbb7c5-7202-4aca-b2b6-daeeb23ad9f9
477
+ # {"errno":null,"errMsg":null,"data":{"id":"eadbb7c5-7202-4aca-b2b6-daeeb23ad9f9","month":"2026-09","state":"running","attempts":1,"createdAt":"2026-10-17T15:00:46.797Z"}}
478
+
479
+ curl localhost:8081/reports/eadbb7c5-7202-4aca-b2b6-daeeb23ad9f9
480
+ # {"errno":null,"errMsg":null,"data":{"id":"eadbb7c5-7202-4aca-b2b6-daeeb23ad9f9","month":"2026-09","state":"done","attempts":1,"createdAt":"2026-10-17T15:00:46.797Z","result":{"orders":209,"revenue":7837.5},"finishedAt":"2026-10-17T15:00:47.303Z"}}
481
+
482
+ curl -i -X POST localhost:8080/reports -H 'Content-Type: application/json' -d '{"month": "2026-13"}'
483
+ # HTTP/1.1 400 Bad Request
484
+ # {"errno":"E_INVALID_VALUE","errMsg":"\"month\" with value \"2026-13\" fails to match the YYYY-MM pattern","data":null}
485
+
486
+ curl -i localhost:8081/reports/5b0f8a52-2f4e-4a3c-9a55-0b1c3c4d5e6f
487
+ # HTTP/1.1 404 Not Found
488
+ # {"errno":"E_REPORT_NOT_FOUND","errMsg":"The report 5b0f8a52-2f4e-4a3c-9a55-0b1c3c4d5e6f does not exist","data":null}
489
+ ```
490
+
491
+ The first instance, which received the request, builds the report in half a second; the second one reads its file.
492
+
493
+ Every 5 minutes, at :00, :05, :10…, one of the two instances deletes the old reports. This time it was the first one,
494
+ which wrote in its terminal:
495
+
496
+ ```
497
+ app │ [23:05:00.009] INFO (reports/97338): 0 old reports deleted
498
+ ```
499
+
500
+ The second instance did not get the lock and skipped the run, without writing anything to its log. No report was
501
+ deleted: the reports are kept for a day.
502
+
503
+ ## 8. Test it
504
+
505
+ The test of the template is replaced by a test of the reports. It starts the service with its reports and its locks in
506
+ a temporary directory:
507
+
508
+ **`packages/app/src/tests/App.spec.ts`**
509
+
510
+ ```typescript
511
+ import {after, before, describe, it, type TestContext} from 'node:test'
512
+ import assert from 'node:assert'
513
+ import {mkdtemp, rm} from 'node:fs/promises'
514
+ import os from 'node:os'
515
+ import path from 'node:path'
516
+ import {setTimeout} from 'node:timers/promises'
517
+ import {Application} from 'lakutata'
518
+ import {type Queue} from 'lakutata/com/queue'
519
+ import {config} from '../config/Config.js'
520
+ import {type Report, type Reports} from '../components/Reports.js'
521
+ import {type Sales} from '../components/Sales.js'
522
+
523
+ const PORT: number = Number(process.env.TEST_PORT ?? 18087)
524
+
525
+ /**
526
+ * A request to the service: its status and its {errno, errMsg, data}
527
+ * @param method
528
+ * @param route
529
+ * @param body
530
+ */
531
+ async function request(method: string, route: string, body?: unknown): Promise<{ status: number, errno: string | null, data: any }> {
532
+ const response: Response = await fetch(`http://127.0.0.1:${PORT}${route}`, {
533
+ method: method,
534
+ headers: {'Authorization': 'Bearer test-token', 'Content-Type': 'application/json'},
535
+ body: body === undefined ? undefined : JSON.stringify(body)
536
+ })
537
+ const envelope = await response.json() as { errno: string | null, data: any }
538
+ return {status: response.status, errno: envelope.errno, data: envelope.data}
539
+ }
540
+
541
+ /**
542
+ * Read a report until its job has ended (done or failed)
543
+ * @param id
544
+ */
545
+ async function finished(id: string): Promise<Report> {
546
+ for (; ;) {
547
+ const report: Report = (await request('GET', `/reports/${id}`)).data
548
+ if (report.state === 'done' || report.state === 'failed') return report
549
+ await setTimeout(100)
550
+ }
551
+ }
552
+
553
+ describe('reports', (): void => {
554
+
555
+ let app: Application
556
+ let data: string
557
+
558
+ before(async (): Promise<void> => {
559
+ process.env.API_TOKEN = 'test-token'
560
+ //The reports and the locks in a temporary directory
561
+ data = await mkdtemp(path.join(os.tmpdir(), 'reports-'))
562
+ app = await new Promise<Application>((resolve: (app: Application) => void): void => {
563
+ Application.run(config(PORT, data)).onLaunched((launched: Application): void => resolve(launched))
564
+ })
565
+ })
566
+
567
+ after(async (): Promise<void> => {
568
+ await app?.dispose()
569
+ await rm(data, {recursive: true, force: true})
570
+ })
571
+
572
+ it('builds a report in the background', async (): Promise<void> => {
573
+ const created = await request('POST', '/reports', {month: '2026-09'})
574
+ assert.strictEqual(created.status, 202)
575
+ assert.strictEqual(created.data.state, 'queued')
576
+ //The result of the job, kept by the queue
577
+ const queue: Queue = await app.getObject<Queue>('queue')
578
+ assert.deepStrictEqual(await queue.result('report.build', created.data.id), {orders: 209, revenue: 7837.5})
579
+ const report = await request('GET', `/reports/${created.data.id}`)
580
+ assert.strictEqual(report.data.state, 'done')
581
+ assert.deepStrictEqual(report.data.result, {orders: 209, revenue: 7837.5})
582
+ assert.strictEqual(report.data.attempts, 1)
583
+ })
584
+
585
+ it('retries a job that failed', async (t: TestContext): Promise<void> => {
586
+ const sales: Sales = await app.getObject<Sales>('sales')
587
+ //The first call fails, the next ones run the real code
588
+ const summarize = t.mock.method(sales, 'summarize')
589
+ summarize.mock.mockImplementationOnce(async (): Promise<never> => {
590
+ throw new Error('The database did not answer')
591
+ })
592
+ const created = await request('POST', '/reports', {month: '2026-08'})
593
+ const report: Report = await finished(created.data.id)
594
+ assert.strictEqual(report.state, 'done')
595
+ assert.strictEqual(report.attempts, 2)
596
+ assert.strictEqual(summarize.mock.callCount(), 2)
597
+ })
598
+
599
+ it('fails a job after its attempts', async (t: TestContext): Promise<void> => {
600
+ const sales: Sales = await app.getObject<Sales>('sales')
601
+ t.mock.method(sales, 'summarize', async (): Promise<never> => {
602
+ throw new Error('The database did not answer')
603
+ })
604
+ const created = await request('POST', '/reports', {month: '2026-07'})
605
+ const report: Report = await finished(created.data.id)
606
+ assert.strictEqual(report.state, 'failed')
607
+ assert.strictEqual(report.attempts, 3)
608
+ assert.strictEqual(report.error, 'The database did not answer')
609
+ })
610
+
611
+ it('refuses an invalid month and an unknown report', async (): Promise<void> => {
612
+ const invalid = await request('POST', '/reports', {month: '2026-13'})
613
+ assert.strictEqual(invalid.status, 400)
614
+ assert.strictEqual(invalid.errno, 'E_INVALID_VALUE')
615
+ const unknown = await request('GET', '/reports/5b0f8a52-2f4e-4a3c-9a55-0b1c3c4d5e6f')
616
+ assert.strictEqual(unknown.status, 404)
617
+ assert.strictEqual(unknown.errno, 'E_REPORT_NOT_FOUND')
618
+ })
619
+
620
+ it('deletes the reports finished for more than a day', async (): Promise<void> => {
621
+ //What the schedule runs, called directly: now, then as if it were tomorrow
622
+ const reports: Reports = await app.getObject<Reports>('reports')
623
+ assert.strictEqual(await reports.clean(new Date()), 0)
624
+ assert.strictEqual(await reports.clean(new Date(Date.now() + 25 * 60 * 60 * 1000)), 3)
625
+ })
626
+ })
627
+ ```
628
+
629
+ - The first test reads the result of the job with `queue.result()`, which waits until the message has been processed,
630
+ then reads the report.
631
+ - To make a job fail, the tests replace the `summarize()` method of the `sales` component with `t.mock.method()` from
632
+ `node:test`: for one call, to see the job succeed on its second attempt, then for every call, to see it fail after
633
+ its 3 attempts, which takes 3 seconds (the backoff). The component is shared, so the action calls the replaced
634
+ method, which is restored at the end of the test.
635
+ - The last test calls `clean()` directly, as if it were tomorrow, rather than waiting for the cron expression.
636
+
637
+ ```bash
638
+ npm test
639
+ ```
640
+
641
+ ```
642
+ app │ ✔ builds a report in the background
643
+ app │ ✔ retries a job that failed
644
+ app │ ✔ fails a job after its attempts
645
+ app │ ✔ refuses an invalid month and an unknown report
646
+ app │ ✔ deletes the reports finished for more than a day
647
+ app │ ℹ pass 5
648
+ ✔ test ran in app
649
+ ```
650
+
651
+ The service logs each failed attempt, with its error: `The queue action ReportController:build failed on the message
652
+ <id> of report.build (attempt 1 of 3)`. `queueEntrypoint('queue', {onError})` replaces this log.
653
+
654
+ ## 9. With several machines
655
+
656
+ The example runs its instances on one machine. On several machines, each part needs a driver that the machines share,
657
+ and the code of the actions does not change:
658
+
659
+ - The queue: the memory driver keeps the messages in one process. An instance builds only the reports it was asked
660
+ for, and the messages still waiting when it stops are lost: their reports stay `queued`, and the schedule never
661
+ deletes them. With Redis (`driver: {type: 'redis', url: process.env.REDIS_URL}`, and
662
+ `npm install bullmq ioredis -w app`), the messages survive restarts, any instance can process them, and the results
663
+ are shared ([Queue](../Queue.md)).
664
+ - The locks: the file driver shares them only between the processes of one machine. Redis
665
+ (`driver: {type: 'redis', url}`, and `npm install redis -w app`) or PostgreSQL share them between machines
666
+ ([Mutex](../Mutex.md)).
667
+ - The reports: a database table ([A REST API with a database](RestAPI.md)), or a volume that every instance mounts as
668
+ `DATA`.
669
+
670
+ `npm start` runs the built service, and `npm run docker:build` makes its image (the template's `Dockerfile`).
671
+
672
+ <!--
673
+ check:
674
+ create: reports service
675
+ remove: packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts
676
+ run: npm test
677
+ -->