lakutata 3.1.4 → 3.1.6

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 (67) hide show
  1. package/README.md +11 -11
  2. package/README.zh-CN.md +5 -5
  3. package/dist/cjs/exports/NwjsAdapter.d.ts +1 -0
  4. package/dist/cjs/exports/NwjsAdapter.js +20 -0
  5. package/dist/cjs/exports/ReactNativeAdapter.d.ts +1 -0
  6. package/dist/cjs/exports/ReactNativeAdapter.js +20 -0
  7. package/dist/esm/exports/NwjsAdapter.js +1 -0
  8. package/dist/esm/exports/ReactNativeAdapter.js +1 -0
  9. package/dist/types/exports/NwjsAdapter.d.ts +1 -0
  10. package/dist/types/exports/ReactNativeAdapter.d.ts +1 -0
  11. package/doc/en/Application.md +6 -1
  12. package/doc/en/Barcode.md +386 -0
  13. package/doc/en/Cache.md +2 -1
  14. package/doc/en/Command.md +20 -4
  15. package/doc/en/ContainerdIntegration.md +1 -1
  16. package/doc/en/Database.md +4 -0
  17. package/doc/en/DatabaseDrivers.md +75 -4
  18. package/doc/en/Entrypoint.md +4 -2
  19. package/doc/en/Exceptions.md +1 -1
  20. package/doc/en/FFI.md +1 -1
  21. package/doc/en/GRPC.md +1 -1
  22. package/doc/en/GettingStarted.md +16 -2
  23. package/doc/en/HTTP.md +1 -0
  24. package/doc/en/I18n.md +1 -1
  25. package/doc/en/Installers.md +34 -6
  26. package/doc/en/Math.md +1 -1
  27. package/doc/en/Migrations.md +2 -2
  28. package/doc/en/NativeAddons.md +12 -2
  29. package/doc/en/Overview.md +14 -11
  30. package/doc/en/Packaging.md +8 -1
  31. package/doc/en/ProjectStructure.md +18 -3
  32. package/doc/en/ServiceCalls.md +85 -5
  33. package/doc/en/UpgradingFrom1.md +2 -2
  34. package/doc/en/UpgradingFrom2.md +4 -4
  35. package/doc/en/WebRTC.md +4 -4
  36. package/doc/en/examples/MobileApp.md +730 -0
  37. package/doc/en/examples/README.md +2 -0
  38. package/doc/en/examples/TrayApp.md +1061 -0
  39. package/doc/zh/Application.md +3 -1
  40. package/doc/zh/Barcode.md +313 -0
  41. package/doc/zh/Cache.md +1 -1
  42. package/doc/zh/Command.md +12 -3
  43. package/doc/zh/ContainerdIntegration.md +1 -1
  44. package/doc/zh/Database.md +2 -0
  45. package/doc/zh/DatabaseDrivers.md +52 -2
  46. package/doc/zh/Entrypoint.md +3 -1
  47. package/doc/zh/Exceptions.md +1 -1
  48. package/doc/zh/FFI.md +1 -1
  49. package/doc/zh/GRPC.md +1 -1
  50. package/doc/zh/GettingStarted.md +12 -2
  51. package/doc/zh/HTTP.md +1 -0
  52. package/doc/zh/I18n.md +1 -1
  53. package/doc/zh/Installers.md +16 -5
  54. package/doc/zh/Math.md +1 -1
  55. package/doc/zh/Migrations.md +1 -1
  56. package/doc/zh/NativeAddons.md +4 -2
  57. package/doc/zh/Overview.md +5 -4
  58. package/doc/zh/Packaging.md +6 -1
  59. package/doc/zh/ProjectStructure.md +5 -2
  60. package/doc/zh/ServiceCalls.md +65 -2
  61. package/doc/zh/UpgradingFrom1.md +2 -2
  62. package/doc/zh/UpgradingFrom2.md +3 -3
  63. package/doc/zh/WebRTC.md +1 -1
  64. package/doc/zh/examples/MobileApp.md +671 -0
  65. package/doc/zh/examples/README.md +2 -0
  66. package/doc/zh/examples/TrayApp.md +980 -0
  67. package/package.json +36 -16
@@ -0,0 +1,730 @@
1
+ # A mobile application
2
+
3
+ This example builds an expense book for Android, iOS and HarmonyOS: the user writes down what they spend, and sees the
4
+ total of each month and of each category. It starts from the `mobile-react` template and shows the two sides of a mobile
5
+ application made with lakutata: the Node.js side, a lakutata application running in the Node.js that the mobile
6
+ application embeds, which keeps the expenses in an SQLite database of the data directory of the application and brings
7
+ the database up to date with its migrations; and the user interface of React Native, the same on the three systems,
8
+ which calls the actions of the Node.js side through the native module and receives its events. It also shows the tests
9
+ of the Node.js side, which run on the computer, without a device.
10
+
11
+ | Message | What it does |
12
+ |---|---|
13
+ | `{cmd: 'expenses.add', amount: 1250, category: 'Food', note: 'Lunch', spentOn: '2026-11-04'}` | Adds an expense of 12.50 |
14
+ | `{cmd: 'expenses.month', month: '2026-11'}` | Gives the expenses of November 2026, their total and the total of each category |
15
+ | `{cmd: 'expenses.delete', id: 1}` | Deletes an expense |
16
+
17
+ When an expense is added or deleted, the Node.js side pushes the `expenses.changed` event, with its month, to the user
18
+ interface.
19
+
20
+ ## 1. Create the project
21
+
22
+ ```bash
23
+ npx @lakutata/cli create --name expenses --template mobile-react --yes
24
+ cd expenses
25
+ npm install better-sqlite3 -w app
26
+ rm packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts
27
+ ```
28
+
29
+ The root of the project is the application of React Native, with its native projects `android`, `ios` and `harmony`; its
30
+ parts are packages of a workspace ([ProjectStructure](../ProjectStructure.md)): `packages/app` is the Node.js side,
31
+ `packages/ui` the user interface, and `packages/ipc` what connects them. The README of the project lists what each
32
+ system needs on the computer: the Android SDK with its NDK, Xcode, DevEco Studio. The Node.js side uses SQLite through
33
+ `better-sqlite3`, installed here: an addon in C++, which `lakutata mobile stage` builds from its sources for each system
34
+ with those tools ([DatabaseDrivers](../DatabaseDrivers.md), section "The mobile applications"). The example controller
35
+ of the template is removed.
36
+
37
+ ## 2. The entity
38
+
39
+ **`packages/app/src/entities/Expense.ts`**
40
+
41
+ ```typescript
42
+ import {Column, CreateDateColumn, Entity, PrimaryGeneratedColumn} from 'lakutata/decorator/orm'
43
+
44
+ /**
45
+ * An expense: its amount in cents, its category, a note, and the day it was spent on
46
+ */
47
+ @Entity('expenses')
48
+ export class Expense {
49
+ @PrimaryGeneratedColumn()
50
+ public id: number
51
+
52
+ @Column({type: 'integer'})
53
+ public amount: number
54
+
55
+ @Column({type: 'varchar', length: 40})
56
+ public category: string
57
+
58
+ @Column({type: 'varchar', length: 200, default: ''})
59
+ public note: string
60
+
61
+ //The day as YYYY-MM-DD: its first seven characters are the month
62
+ @Column({type: 'varchar', length: 10})
63
+ public spentOn: string
64
+
65
+ @CreateDateColumn()
66
+ public createdAt: Date
67
+ }
68
+ ```
69
+
70
+ An entity is a class whose decorators describe its table ([Entities](../Entities.md)). An amount is an integer of cents:
71
+ 12.50 is 1250, and the totals add integers, without the rounding errors of decimal numbers. The day is text,
72
+ `YYYY-MM-DD`, so the expenses of a month are those whose day starts with `2026-11-`.
73
+
74
+ ## 3. The migration
75
+
76
+ **`packages/app/src/migrations/1793722916138-database.ts`**
77
+
78
+ ```typescript
79
+ import { MigrationInterface, QueryRunner } from "lakutata/orm";
80
+
81
+ export class Migrations1793722916138 implements MigrationInterface {
82
+ name = 'Migrations1793722916138'
83
+
84
+ public async up(queryRunner: QueryRunner): Promise<void> {
85
+ await queryRunner.query(`
86
+ CREATE TABLE "expenses" (
87
+ "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
88
+ "amount" integer NOT NULL,
89
+ "category" varchar(40) NOT NULL,
90
+ "note" varchar(200) NOT NULL DEFAULT (''),
91
+ "spentOn" varchar(10) NOT NULL,
92
+ "createdAt" datetime NOT NULL DEFAULT (datetime('now'))
93
+ )
94
+ `);
95
+ }
96
+
97
+ public async down(queryRunner: QueryRunner): Promise<void> {
98
+ await queryRunner.query(`
99
+ DROP TABLE "expenses"
100
+ `);
101
+ }
102
+
103
+ }
104
+ ```
105
+
106
+ The database lives on the device of each user, and an update of the application has to bring it up to date: its schema
107
+ changes through migrations, never through `synchronize`, which may drop a column with its data
108
+ ([Migrations](../Migrations.md)). The application wrote this one itself, from its entities, with the
109
+ `lakutata migration` command ([Migrations](../Migrations.md), section 2), run on the computer, where its database is
110
+ `packages/app/data/expenses.sqlite`:
111
+
112
+ ```bash
113
+ npx lakutata migration generate --package app
114
+ ```
115
+
116
+ The migration you generate has another timestamp in its name: import it in the options under that name (section 7). When
117
+ an entity changes, bring the database of the computer up to date first, with `npx lakutata migration run --package app`,
118
+ as the command compares the entities with its tables; then generate the next migration, which the options list after
119
+ this one.
120
+
121
+ ## 4. The input
122
+
123
+ **`packages/app/src/dtos/ExpenseDTO.ts`**
124
+
125
+ ```typescript
126
+ import {DTO} from 'lakutata'
127
+ import {Expect} from 'lakutata/decorator/dto'
128
+
129
+ /**
130
+ * An expense by its id
131
+ */
132
+ export class ExpenseIdDTO extends DTO {
133
+ @Expect(DTO.Number().integer().positive().required())
134
+ public id: number
135
+ }
136
+
137
+ /**
138
+ * A new expense
139
+ */
140
+ export class AddExpenseDTO extends DTO {
141
+ //In cents (12.50 is 1250): integers add up without the rounding errors of decimal numbers
142
+ @Expect(DTO.Number().integer().positive().max(100000000).required())
143
+ public amount: number
144
+
145
+ @Expect(DTO.String().min(1).max(40).required())
146
+ public category: string
147
+
148
+ @Expect(DTO.String().allow('').max(200).optional().default(''))
149
+ public note: string
150
+
151
+ //The day of the device, which the user interface gives: the time zone of the user decides what today is
152
+ @Expect(DTO.String().pattern(/^\d{4}-\d{2}-\d{2}$/, 'day').required())
153
+ public spentOn: string
154
+ }
155
+
156
+ /**
157
+ * A month, as YYYY-MM
158
+ */
159
+ export class MonthDTO extends DTO {
160
+ @Expect(DTO.String().pattern(/^\d{4}-(0[1-9]|1[0-2])$/, 'month').required())
161
+ public month: string
162
+ }
163
+ ```
164
+
165
+ Each action validates its input with a DTO before it runs ([DTOValidation](../DTOValidation.md)). A message is JSON, so
166
+ its values keep their types: `amount` must be an integer, and `12.5` is refused with `"amount" must be an integer`. The
167
+ day is the one of the device, which the user interface gives: the Node.js side does not decide what today is for the
168
+ user.
169
+
170
+ ## 5. The exception
171
+
172
+ **`packages/app/src/exceptions/ExpenseNotFoundException.ts`**
173
+
174
+ ```typescript
175
+ import {Exception} from 'lakutata'
176
+
177
+ /**
178
+ * The expense of the request does not exist
179
+ */
180
+ export class ExpenseNotFoundException extends Exception {
181
+ public errno: string = 'E_EXPENSE_NOT_FOUND'
182
+ }
183
+ ```
184
+
185
+ An exception has an `errno`, the code that the user interface checks ([Exceptions](../Exceptions.md)). The user
186
+ interface receives the error as it was thrown, with all its properties: `ipc.invoke()` rejects with it.
187
+
188
+ ## 6. The controller
189
+
190
+ **`packages/app/src/controllers/ExpenseController.ts`**
191
+
192
+ ```typescript
193
+ import {Controller, type Entrypoint} from 'lakutata/com/entrypoint'
194
+ import {type Database} from 'lakutata/com/database'
195
+ import {ServiceAction} from 'lakutata/decorator/ctrl'
196
+ import {Inject} from 'lakutata/decorator/di'
197
+ import {Like, type Repository} from 'lakutata/orm'
198
+ import {Expense} from '../entities/Expense.js'
199
+ import {AddExpenseDTO, ExpenseIdDTO, MonthDTO} from '../dtos/ExpenseDTO.js'
200
+ import {ExpenseNotFoundException} from '../exceptions/ExpenseNotFoundException.js'
201
+
202
+ /**
203
+ * The expenses of a month: their total, the total of each category (the largest first), and the expenses (the latest
204
+ * first)
205
+ */
206
+ export type MonthSummary = { month: string, total: number, categories: { category: string, total: number }[], expenses: Expense[] }
207
+
208
+ /**
209
+ * The actions of the expenses, invoked by the user interface: ipc.invoke({cmd: 'expenses.month', month: '2026-11'})
210
+ */
211
+ export class ExpenseController extends Controller {
212
+
213
+ /**
214
+ * Pushes the events to the clients: the user interface (ipc.on), the tools in development (GET /events)
215
+ */
216
+ @Inject('entrypoint')
217
+ protected readonly entrypoint: Entrypoint
218
+
219
+ @Inject('database')
220
+ protected readonly database: Database
221
+
222
+ protected get expenses(): Repository<Expense> {
223
+ return this.database.getRepository(Expense)
224
+ }
225
+
226
+ @ServiceAction({cmd: 'expenses.add'}, AddExpenseDTO)
227
+ public async add(input: AddExpenseDTO): Promise<Expense> {
228
+ const expense: Expense = await this.expenses.save(this.expenses.create({amount: input.amount, category: input.category, note: input.note, spentOn: input.spentOn}))
229
+ //The month changed
230
+ this.entrypoint.broadcast('expenses.changed', expense.spentOn.slice(0, 7))
231
+ return expense
232
+ }
233
+
234
+ @ServiceAction({cmd: 'expenses.month'}, MonthDTO)
235
+ public async month(input: MonthDTO): Promise<MonthSummary> {
236
+ const expenses: Expense[] = await this.expenses.find({where: {spentOn: Like(`${input.month}-%`)}, order: {spentOn: 'DESC', id: 'DESC'}})
237
+ const totals: Map<string, number> = new Map()
238
+ for (const expense of expenses) totals.set(expense.category, (totals.get(expense.category) ?? 0) + expense.amount)
239
+ return {
240
+ month: input.month,
241
+ total: expenses.reduce((sum: number, expense: Expense): number => sum + expense.amount, 0),
242
+ categories: [...totals].map(([category, total]) => ({category: category, total: total})).sort((a, b): number => b.total - a.total),
243
+ expenses: expenses
244
+ }
245
+ }
246
+
247
+ @ServiceAction({cmd: 'expenses.delete'}, ExpenseIdDTO)
248
+ public async remove(input: ExpenseIdDTO): Promise<void> {
249
+ const expense: Expense | null = await this.expenses.findOneBy({id: input.id})
250
+ if (!expense) throw new ExpenseNotFoundException(`The expense ${input.id} does not exist`)
251
+ await this.expenses.remove(expense)
252
+ this.entrypoint.broadcast('expenses.changed', expense.spentOn.slice(0, 7))
253
+ }
254
+ }
255
+ ```
256
+
257
+ - Each `@ServiceAction` is called by a message ([ServiceCalls](../ServiceCalls.md), section 2): the user interface sends
258
+ it with `ipc.invoke({cmd: 'expenses.month', month: '2026-11'})`, through the native module and its socket to Node.js.
259
+ - `this.entrypoint.broadcast()` pushes `expenses.changed` to every client: the user interface, and the tools of the
260
+ development (section 10). Its argument is the month that changed, so a user interface showing another month does not
261
+ load it again.
262
+ - `Like` comes from `lakutata/orm`, the API of TypeORM ([Database](../Database.md)).
263
+
264
+ ## 7. The options of the application
265
+
266
+ **`packages/app/src/config/Config.ts`**
267
+
268
+ ```typescript
269
+ import type {Duplex} from 'node:stream'
270
+ import type {ApplicationOptions} from 'lakutata'
271
+ import {buildDatabaseOptions} from 'lakutata/com/database'
272
+ import {buildEntrypoints, httpServiceEntrypoint} from 'lakutata/com/entrypoint'
273
+ import {reactNativeServiceEntrypoint} from 'lakutata/adapter/react-native'
274
+ import {ExpenseController} from '../controllers/ExpenseController.js'
275
+ import {Expense} from '../entities/Expense.js'
276
+ import {Migrations1793722916138} from '../migrations/1793722916138-database.js'
277
+
278
+ export type ConfigOptions = {
279
+ mode?: 'development' | 'production'
280
+ /**
281
+ * The host serving the actions to the user interface: 'native', the native module of the mobile application (the
282
+ * socket of LAKUTATA_NODE_FD), or a socket standing for it (the tests); none on the computer of the development
283
+ */
284
+ host?: 'native' | Duplex
285
+ /**
286
+ * The port of an HTTP entrypoint for development, none by default. The tools (Postman, curl) invoke the actions with
287
+ * POST / and the message (the pattern and the input), and receive the events pushed to them with GET /events
288
+ * (Server-Sent Events), giving their client id in the id header (or the id query parameter)
289
+ */
290
+ devPort?: number
291
+ }
292
+
293
+ /**
294
+ * The options of the application: App.ts gives run() a function that calls it
295
+ * @param options
296
+ */
297
+ export function config(options: ConfigOptions = {}): ApplicationOptions {
298
+ return {
299
+ id: 'expenses',
300
+ name: 'expenses',
301
+ mode: options.mode ?? 'development',
302
+ components: {
303
+ //The expenses, in the data directory of the application (@data)
304
+ database: buildDatabaseOptions({
305
+ type: 'better-sqlite3',
306
+ database: '@data/expenses.sqlite',
307
+ entities: [Expense],
308
+ //The migrations go into the bundle of the application; those the database has not run yet run when it
309
+ //connects, so an update of the application brings the database of the device up to date
310
+ migrations: [Migrations1793722916138],
311
+ migrationsRun: true
312
+ }),
313
+ entrypoint: buildEntrypoints({
314
+ controllers: [ExpenseController],
315
+ //The user interface invokes the actions through the native module: ipc.invoke({cmd: 'expenses.month', month: '2026-11'})
316
+ service: [
317
+ ...options.host === undefined ? [] : [reactNativeServiceEntrypoint(options.host === 'native' ? {} : {socket: options.host})],
318
+ ...options.devPort === undefined ? [] : [httpServiceEntrypoint({port: options.devPort})]
319
+ ]
320
+ })
321
+ },
322
+ //The database is connected, and brought up to date by its migrations, before the actions are served
323
+ bootstrap: ['database', 'entrypoint']
324
+ }
325
+ }
326
+ ```
327
+
328
+ - The database is a file of the data directory of the application, `@data/expenses.sqlite`; the alias is resolved when
329
+ the database connects. The `App.ts` of the template declares the alias `@data` for each system: the `files` directory
330
+ of the application on Android, `Library/Application Support` on iOS, `/data/storage/el2/base/files` on HarmonyOS, and
331
+ `packages/app/data` on the computer. The system keeps it until the application is uninstalled, unlike `os.tmpdir()`,
332
+ its cache.
333
+ - The migrations are listed as classes. The Node.js side is a bundle on the device (`lakutata mobile stage`), without
334
+ the compiled files that a glob such as `build/migrations/*.js` would find. `migrationsRun: true` runs the migrations
335
+ that the database of the device has not run yet when it connects, so an update of the application brings its database
336
+ up to date at its first launch.
337
+ - `bootstrap` connects the database, and migrates it, before the actions are served.
338
+
339
+ ## 8. The user interface
340
+
341
+ **`packages/ui/src/App.tsx`**
342
+
343
+ ```tsx
344
+ import {useCallback, useEffect, useState} from 'react'
345
+ import {Button, Pressable, ScrollView, StatusBar, StyleSheet, Text, TextInput, View} from 'react-native'
346
+ import {SafeAreaProvider, useSafeAreaInsets, type EdgeInsets} from 'react-native-safe-area-context'
347
+ import {ipc} from 'ipc'
348
+
349
+ /**
350
+ * An expense, as the actions give it
351
+ */
352
+ type Expense = { id: number, amount: number, category: string, note: string, spentOn: string }
353
+
354
+ /**
355
+ * The expenses of a month, as expenses.month gives them
356
+ */
357
+ type MonthSummary = { month: string, total: number, categories: { category: string, total: number }[], expenses: Expense[] }
358
+
359
+ /**
360
+ * A day of the device as YYYY-MM-DD, in its time zone
361
+ * @param date
362
+ */
363
+ function day(date: Date): string {
364
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
365
+ }
366
+
367
+ /**
368
+ * The month before or after a month (YYYY-MM)
369
+ * @param month
370
+ * @param offset
371
+ */
372
+ function shift(month: string, offset: number): string {
373
+ const [year, index] = month.split('-').map(Number)
374
+ return day(new Date(year, index - 1 + offset, 1)).slice(0, 7)
375
+ }
376
+
377
+ /**
378
+ * An amount in cents, as 12.50
379
+ * @param cents
380
+ */
381
+ function money(cents: number): string {
382
+ return (cents / 100).toFixed(2)
383
+ }
384
+
385
+ /**
386
+ * The application: its screen in the safe area of the device, as in the template
387
+ */
388
+ export default function App() {
389
+ //The icons of the status bar dark on the light screen, as in the template
390
+ return (
391
+ <SafeAreaProvider>
392
+ <StatusBar barStyle="dark-content"/>
393
+ <Expenses/>
394
+ </SafeAreaProvider>
395
+ )
396
+ }
397
+
398
+ function Expenses() {
399
+ //Clear of the status bar, the camera and the home indicator
400
+ const insets: EdgeInsets = useSafeAreaInsets()
401
+ const [month, setMonth] = useState<string>(day(new Date()).slice(0, 7))
402
+ const [summary, setSummary] = useState<MonthSummary | null>(null)
403
+ const [amount, setAmount] = useState<string>('')
404
+ const [category, setCategory] = useState<string>('')
405
+ const [note, setNote] = useState<string>('')
406
+ const [failure, setFailure] = useState<string>('')
407
+ const [ended, setEnded] = useState<number | null>(null)
408
+
409
+ const load = useCallback((): void => {
410
+ ipc.invoke<MonthSummary>({cmd: 'expenses.month', month: month})
411
+ .then((loaded: MonthSummary): void => setSummary(loaded))
412
+ .catch((error: Error): void => setFailure(error.message))
413
+ }, [month])
414
+
415
+ useEffect((): () => void => {
416
+ load()
417
+ //An expense was added or deleted: the month shown is loaded again if it is the one that changed
418
+ return ipc.on('expenses.changed', (changed: string): void => {
419
+ if (changed === month) load()
420
+ })
421
+ }, [load, month])
422
+
423
+ //Node.js ended (process.exit(), an uncaught exception): the mobile application keeps running
424
+ useEffect((): () => void => ipc.on('exit', (code: number): void => setEnded(code)), [])
425
+
426
+ const restart = (): void => {
427
+ setEnded(null)
428
+ ipc.restart()
429
+ load()
430
+ }
431
+
432
+ const add = (): void => {
433
+ setFailure('')
434
+ //12.5 or 12,50 as cents
435
+ const cents: number = Math.round(Number(amount.replace(',', '.')) * 100)
436
+ ipc.invoke({cmd: 'expenses.add', amount: cents, category: category.trim(), note: note.trim(), spentOn: day(new Date())})
437
+ .then((): void => {
438
+ setAmount('')
439
+ setNote('')
440
+ })
441
+ .catch((error: Error): void => setFailure(error.message))
442
+ }
443
+
444
+ const remove = (id: number): void => {
445
+ ipc.invoke({cmd: 'expenses.delete', id: id}).catch((error: Error): void => setFailure(error.message))
446
+ }
447
+
448
+ return (
449
+ //A tap on a button reaches it while the keyboard is shown (by default, the tap only hides the keyboard)
450
+ <ScrollView contentContainerStyle={{paddingTop: insets.top + 24, paddingBottom: insets.bottom + 24, paddingLeft: insets.left + 24, paddingRight: insets.right + 24}} keyboardShouldPersistTaps="handled">
451
+ <View style={styles.row}>
452
+ <Button title="‹" onPress={(): void => setMonth(shift(month, -1))}/>
453
+ <Text style={styles.title}>{month}</Text>
454
+ <Button title="›" onPress={(): void => setMonth(shift(month, 1))}/>
455
+ </View>
456
+ <Text style={styles.total}>{money(summary?.total ?? 0)}</Text>
457
+ {summary?.categories.map(({category: name, total}) => (
458
+ <Text key={name} style={styles.text}>{name}: {money(total)}</Text>
459
+ ))}
460
+ <TextInput style={styles.input} placeholder="Amount" keyboardType="decimal-pad" value={amount} onChangeText={setAmount}/>
461
+ <TextInput style={styles.input} placeholder="Category" value={category} onChangeText={setCategory}/>
462
+ <TextInput style={styles.input} placeholder="Note" value={note} onChangeText={setNote}/>
463
+ <Button title="Add today's expense" onPress={add}/>
464
+ {failure !== '' && <Text style={styles.failure}>{failure}</Text>}
465
+ {ended !== null && (
466
+ <>
467
+ <Text style={styles.failure}>Node.js ended (exit code {ended})</Text>
468
+ <Button title="Restart" onPress={restart}/>
469
+ </>
470
+ )}
471
+ {summary?.expenses.map((expense: Expense) => (
472
+ <Pressable key={expense.id} style={styles.expense} onLongPress={(): void => remove(expense.id)}>
473
+ <Text style={styles.text}>{expense.spentOn} {expense.category} {money(expense.amount)}</Text>
474
+ {expense.note !== '' && <Text style={styles.note}>{expense.note}</Text>}
475
+ </Pressable>
476
+ ))}
477
+ {summary?.expenses.length ? <Text style={styles.note}>Hold an expense to delete it</Text> : null}
478
+ </ScrollView>
479
+ )
480
+ }
481
+
482
+ const styles = StyleSheet.create({
483
+ row: {flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 24},
484
+ title: {fontSize: 20, fontWeight: 'bold'},
485
+ total: {fontSize: 32, fontWeight: 'bold', marginVertical: 16, textAlign: 'center'},
486
+ text: {fontSize: 16, marginBottom: 4},
487
+ note: {fontSize: 14, color: '#666'},
488
+ input: {borderWidth: 1, borderColor: '#ccc', borderRadius: 6, padding: 10, marginVertical: 6, fontSize: 16},
489
+ failure: {color: '#c00', marginVertical: 8},
490
+ expense: {paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: '#eee'}
491
+ })
492
+ ```
493
+
494
+ - The screen is in the safe area of the device, as in the template: `useSafeAreaInsets()` gives the room that the status
495
+ bar, the camera and the home indicator take.
496
+ - `ipc` comes from the `ipc` package of the template: `ipc.invoke()` calls an action and gives its `payload`, or rejects
497
+ with its error; `ipc.on()` listens to an event and returns the function that removes the listener.
498
+ - The month shown is loaded again on `expenses.changed` when it is the month that changed.
499
+ - `keyboardShouldPersistTaps="handled"` lets a tap on a button reach it while the keyboard is shown: by default, the tap
500
+ only hides the keyboard, and the expense typed is not added.
501
+ - When Node.js ends (an uncaught exception, `process.exit()`), the mobile application keeps running: `ipc` emits `exit`,
502
+ and `ipc.restart()` starts Node.js again, as in the template.
503
+
504
+ ## 9. Run it
505
+
506
+ Start Metro in a terminal of its own, then the application on an emulator (or a device):
507
+
508
+ ```bash
509
+ npm start
510
+ npm run android # or: npm run ios, npm run harmony
511
+ ```
512
+
513
+ `npm run android` builds the packages, makes the Node.js project of the application (`lakutata mobile stage`: the bundle
514
+ of the Node.js side, and `better-sqlite3` built for the architectures of Android), builds the debug application,
515
+ installs it and starts it. The first time, it downloads the libnode of the system. The user interface comes from Metro,
516
+ and its changes show at once; a change of `packages/app` is put into the installed application by
517
+ `npm run deploy:android` (or `deploy:ios`, `deploy:harmony`), without installing it again.
518
+
519
+ Add an expense, close the application (on Android, swipe it away from the recent applications), and open it again: the
520
+ month still lists the expense, read from the database of the data directory. Hold an expense to delete it.
521
+
522
+ ## 10. Try it on the computer
523
+
524
+ `npm run dev -w app` runs the Node.js side on the computer, its database in `packages/app/data`, and serves its actions
525
+ over HTTP on port 9000 (`DEV_PORT`) for tools such as curl or Postman: `POST /` sends a message, and `GET /events`
526
+ streams the events of a client ([ServiceCalls](../ServiceCalls.md), section 4).
527
+
528
+ ```bash
529
+ npm run dev -w app
530
+ ```
531
+
532
+ In another terminal:
533
+
534
+ ```bash
535
+ curl -N 'localhost:9000/events?id=me' &
536
+ # event: client
537
+ # data: ["me"]
538
+
539
+ curl -X POST localhost:9000/ -H 'Content-Type: application/json' -d '{"cmd": "expenses.add", "amount": 1250, "category": "Food", "note": "Lunch", "spentOn": "2026-11-04"}'
540
+ # {"success":true,"error":null,"payload":{"amount":1250,"category":"Food","note":"Lunch","spentOn":"2026-11-04","id":1,"createdAt":"2026-11-03T16:30:55.000Z"}}
541
+ # event: expenses.changed
542
+ # data: ["2026-11"]
543
+
544
+ curl -X POST localhost:9000/ -H 'Content-Type: application/json' -d '{"cmd": "expenses.add", "amount": 4000, "category": "Transport", "note": "Train", "spentOn": "2026-11-04"}'
545
+
546
+ curl -X POST localhost:9000/ -H 'Content-Type: application/json' -d '{"cmd": "expenses.month", "month": "2026-11"}'
547
+ # {"success":true,"error":null,"payload":{"month":"2026-11","total":5250,"categories":[{"category":"Transport","total":4000},{"category":"Food","total":1250}],"expenses":[{"id":2,"amount":4000,"category":"Transport","note":"Train","spentOn":"2026-11-04","createdAt":"2026-11-03T16:30:55.000Z"},{"id":1,"amount":1250,"category":"Food","note":"Lunch","spentOn":"2026-11-04","createdAt":"2026-11-03T16:30:55.000Z"}]}}
548
+
549
+ curl -X POST localhost:9000/ -H 'Content-Type: application/json' -d '{"cmd": "expenses.add", "amount": 12.5, "category": "Food", "spentOn": "2026-11-04"}'
550
+ # {"success":false,"error":{…,"statusCode":400,"message":"\"amount\" must be an integer","errMsg":"\"amount\" must be an integer","err":"InvalidValueException","errno":"E_INVALID_VALUE"},"payload":null}
551
+
552
+ curl -X POST localhost:9000/ -H 'Content-Type: application/json' -d '{"cmd": "expenses.delete", "id": 7}'
553
+ # {"success":false,"error":{…,"message":"The expense 7 does not exist","errMsg":"The expense 7 does not exist","err":"ExpenseNotFoundException","errno":"E_EXPENSE_NOT_FOUND"},"payload":null}
554
+ ```
555
+
556
+ The errors carry all the properties of the exception, including its stack (left out here).
557
+
558
+ ## 11. Test it
559
+
560
+ **`packages/app/src/tests/App.spec.ts`**
561
+
562
+ ```typescript
563
+ import {after, before, describe, it} from 'node:test'
564
+ import assert from 'node:assert'
565
+ import {existsSync, mkdtempSync, rmSync} from 'node:fs'
566
+ import {connect, createServer, type Server, type Socket} from 'node:net'
567
+ import os from 'node:os'
568
+ import path from 'node:path'
569
+ import {Application} from 'lakutata'
570
+ import type {ReactNativeServiceMessage} from 'lakutata/adapter/react-native'
571
+ import type {MonthSummary} from '../controllers/ExpenseController.js'
572
+ import {config} from '../config/Config.js'
573
+
574
+ /**
575
+ * The native module of the mobile application in the tests: its end of the socket to the application, the messages
576
+ * it received (a line of JSON each)
577
+ */
578
+ const received: ReactNativeServiceMessage[] = []
579
+ let host: Socket
580
+
581
+ let next: number = 0
582
+
583
+ /**
584
+ * Write a message as the native module does, a line of JSON
585
+ * @param message
586
+ */
587
+ function send(message: Record<string, unknown>): void {
588
+ host.write(`${JSON.stringify(message)}\n`)
589
+ }
590
+
591
+ /**
592
+ * Invoke an action as the user interface does (ipc.invoke)
593
+ * @param data
594
+ */
595
+ async function invoke(data: Record<string, unknown>): Promise<any> {
596
+ const id: number = ++next
597
+ send({type: 'invoke', id: id, clientId: 'ui', data: data})
598
+ for (let attempt: number = 0; attempt < 500; attempt++) {
599
+ const response = received.find((message: ReactNativeServiceMessage): boolean => message.type === 'response' && message.id === id)
600
+ if (response) return response.response
601
+ await new Promise((resolve: (value: unknown) => void): unknown => setTimeout(resolve, 10))
602
+ }
603
+ throw new Error(`No response to ${JSON.stringify(data)}`)
604
+ }
605
+
606
+ /**
607
+ * Two sockets connected to each other: the end of the native module, the end of the application
608
+ */
609
+ async function connectedPair(): Promise<[Socket, Socket]> {
610
+ const server: Server = createServer()
611
+ await new Promise<void>((resolve: () => void): unknown => server.listen(0, '127.0.0.1', resolve))
612
+ const accepted: Promise<Socket> = new Promise((resolve: (socket: Socket) => void): unknown => server.once('connection', resolve))
613
+ const native: Socket = connect((server.address() as { port: number }).port, '127.0.0.1')
614
+ const application: Socket = await accepted
615
+ server.close()
616
+ return [native, application]
617
+ }
618
+
619
+ describe('expenses', (): void => {
620
+
621
+ let app: Application
622
+ let socket: Socket
623
+ //The data directory of the tests, where the database is
624
+ const data: string = mkdtempSync(path.join(os.tmpdir(), 'expenses-'))
625
+
626
+ before(async (): Promise<void> => {
627
+ ;[host, socket] = await connectedPair()
628
+ let buffer: string = ''
629
+ host.setEncoding('utf8')
630
+ host.on('data', (chunk: string): void => {
631
+ buffer += chunk
632
+ for (let end: number = buffer.indexOf('\n'); end >= 0; end = buffer.indexOf('\n')) {
633
+ received.push(JSON.parse(buffer.slice(0, end)) as ReactNativeServiceMessage)
634
+ buffer = buffer.slice(end + 1)
635
+ }
636
+ })
637
+ app = await new Promise<Application>((resolve: (app: Application) => void): void => {
638
+ //@data declared as App.ts declares it
639
+ Application
640
+ .alias({'@data': data}, true)
641
+ .run(() => config({host: socket}))
642
+ .onLaunched((launched: Application): void => resolve(launched))
643
+ })
644
+ })
645
+
646
+ after(async (): Promise<void> => {
647
+ await app?.dispose()
648
+ host?.destroy()
649
+ socket?.destroy()
650
+ rmSync(data, {recursive: true, force: true})
651
+ })
652
+
653
+ it('adds the expenses, and gives the totals of a month', async (): Promise<void> => {
654
+ await invoke({cmd: 'expenses.add', amount: 1250, category: 'Food', note: 'Lunch', spentOn: '2026-11-03'})
655
+ await invoke({cmd: 'expenses.add', amount: 4000, category: 'Transport', spentOn: '2026-11-04'})
656
+ await invoke({cmd: 'expenses.add', amount: 830, category: 'Food', spentOn: '2026-11-04'})
657
+ await invoke({cmd: 'expenses.add', amount: 9900, category: 'Food', spentOn: '2026-10-31'})
658
+ const month: MonthSummary = (await invoke({cmd: 'expenses.month', month: '2026-11'})).payload
659
+ assert.strictEqual(month.total, 6080)
660
+ assert.deepStrictEqual(month.categories, [{category: 'Transport', total: 4000}, {category: 'Food', total: 2080}])
661
+ assert.deepStrictEqual(month.expenses.map((expense): string => `${expense.spentOn} ${expense.amount}`), ['2026-11-04 830', '2026-11-04 4000', '2026-11-03 1250'])
662
+ })
663
+
664
+ it('pushes expenses.changed to the user interface', async (): Promise<void> => {
665
+ //The user interface subscribed to the events, the subscription answered
666
+ send({type: 'subscribe', clientId: 'ui'})
667
+ await invoke({cmd: 'expenses.add', amount: 300, category: 'Coffee', spentOn: '2026-11-05'})
668
+ assert.deepStrictEqual(received.filter((message: ReactNativeServiceMessage): boolean => message.type === 'event'), [{type: 'event', clientId: 'ui', event: 'expenses.changed', args: ['2026-11']}])
669
+ })
670
+
671
+ it('deletes an expense, and refuses one that does not exist', async (): Promise<void> => {
672
+ const [latest] = (await invoke({cmd: 'expenses.month', month: '2026-11'})).payload.expenses
673
+ assert.strictEqual((await invoke({cmd: 'expenses.delete', id: latest.id})).success, true)
674
+ assert.strictEqual((await invoke({cmd: 'expenses.month', month: '2026-11'})).payload.total, 6080)
675
+ const missing = await invoke({cmd: 'expenses.delete', id: latest.id})
676
+ assert.strictEqual(missing.success, false)
677
+ assert.strictEqual(missing.error.errno, 'E_EXPENSE_NOT_FOUND')
678
+ })
679
+
680
+ it('refuses an invalid input', async (): Promise<void> => {
681
+ const decimal = await invoke({cmd: 'expenses.add', amount: 12.5, category: 'Food', spentOn: '2026-11-03'})
682
+ assert.strictEqual(decimal.success, false)
683
+ assert.match(decimal.error.message, /amount/)
684
+ const month = await invoke({cmd: 'expenses.month', month: '2026-13'})
685
+ assert.strictEqual(month.success, false)
686
+ assert.match(month.error.message, /month/)
687
+ })
688
+
689
+ it('keeps the database in the data directory', async (): Promise<void> => {
690
+ assert.ok(existsSync(path.join(data, 'expenses.sqlite')))
691
+ })
692
+ })
693
+ ```
694
+
695
+ The tests run the Node.js side on the computer, the way the native module runs it on a device: a socket stands for the
696
+ one of the native module, and the tests write its messages, a line of JSON each (`invoke`, `subscribe`), and read the
697
+ responses and the events. `@data` is a temporary directory, declared as `App.ts` declares it.
698
+
699
+ ```bash
700
+ npm test
701
+ ```
702
+
703
+ ```
704
+ app │ ✔ adds the expenses, and gives the totals of a month
705
+ app │ ✔ pushes expenses.changed to the user interface
706
+ app │ ✔ deletes an expense, and refuses one that does not exist
707
+ app │ ✔ refuses an invalid input
708
+ app │ ✔ keeps the database in the data directory
709
+ app │ ℹ pass 5
710
+ ✔ test ran in app
711
+ ```
712
+
713
+ The tests use the `better-sqlite3` installed for the Node.js of the computer; the one of each system is built by
714
+ `lakutata mobile stage`.
715
+
716
+ ## 12. Release it
717
+
718
+ The release builds of the three systems come from their native projects: they bundle the user interface into the
719
+ application instead of loading it from Metro, and are signed for their stores. The section "The packaging" of the README
720
+ of the project gives their commands; make the Node.js project first, with `npm run stage:android` (or `stage:ios`,
721
+ `stage:harmony`). The Node.js side is the same in the debug and the release builds, and so are its data directory and
722
+ its migrations: an update installed over an older version keeps the database, and its first launch runs the new
723
+ migrations.
724
+
725
+ <!--
726
+ check:
727
+ create: expenses mobile-react
728
+ remove: packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts
729
+ run: npm test
730
+ -->