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.
- package/README.md +11 -11
- package/README.zh-CN.md +5 -5
- package/dist/cjs/exports/NwjsAdapter.d.ts +1 -0
- package/dist/cjs/exports/NwjsAdapter.js +20 -0
- package/dist/cjs/exports/ReactNativeAdapter.d.ts +1 -0
- package/dist/cjs/exports/ReactNativeAdapter.js +20 -0
- package/dist/esm/exports/NwjsAdapter.js +1 -0
- package/dist/esm/exports/ReactNativeAdapter.js +1 -0
- package/dist/types/exports/NwjsAdapter.d.ts +1 -0
- package/dist/types/exports/ReactNativeAdapter.d.ts +1 -0
- package/doc/en/Application.md +6 -1
- package/doc/en/Barcode.md +386 -0
- package/doc/en/Cache.md +2 -1
- package/doc/en/Command.md +20 -4
- package/doc/en/ContainerdIntegration.md +1 -1
- package/doc/en/Database.md +4 -0
- package/doc/en/DatabaseDrivers.md +75 -4
- package/doc/en/Entrypoint.md +4 -2
- package/doc/en/Exceptions.md +1 -1
- package/doc/en/FFI.md +1 -1
- package/doc/en/GRPC.md +1 -1
- package/doc/en/GettingStarted.md +16 -2
- package/doc/en/HTTP.md +1 -0
- package/doc/en/I18n.md +1 -1
- package/doc/en/Installers.md +34 -6
- package/doc/en/Math.md +1 -1
- package/doc/en/Migrations.md +2 -2
- package/doc/en/NativeAddons.md +12 -2
- package/doc/en/Overview.md +14 -11
- package/doc/en/Packaging.md +8 -1
- package/doc/en/ProjectStructure.md +18 -3
- package/doc/en/ServiceCalls.md +85 -5
- package/doc/en/UpgradingFrom1.md +2 -2
- package/doc/en/UpgradingFrom2.md +4 -4
- package/doc/en/WebRTC.md +4 -4
- package/doc/en/examples/MobileApp.md +730 -0
- package/doc/en/examples/README.md +2 -0
- package/doc/en/examples/TrayApp.md +1061 -0
- package/doc/zh/Application.md +3 -1
- package/doc/zh/Barcode.md +313 -0
- package/doc/zh/Cache.md +1 -1
- package/doc/zh/Command.md +12 -3
- package/doc/zh/ContainerdIntegration.md +1 -1
- package/doc/zh/Database.md +2 -0
- package/doc/zh/DatabaseDrivers.md +52 -2
- package/doc/zh/Entrypoint.md +3 -1
- package/doc/zh/Exceptions.md +1 -1
- package/doc/zh/FFI.md +1 -1
- package/doc/zh/GRPC.md +1 -1
- package/doc/zh/GettingStarted.md +12 -2
- package/doc/zh/HTTP.md +1 -0
- package/doc/zh/I18n.md +1 -1
- package/doc/zh/Installers.md +16 -5
- package/doc/zh/Math.md +1 -1
- package/doc/zh/Migrations.md +1 -1
- package/doc/zh/NativeAddons.md +4 -2
- package/doc/zh/Overview.md +5 -4
- package/doc/zh/Packaging.md +6 -1
- package/doc/zh/ProjectStructure.md +5 -2
- package/doc/zh/ServiceCalls.md +65 -2
- package/doc/zh/UpgradingFrom1.md +2 -2
- package/doc/zh/UpgradingFrom2.md +3 -3
- package/doc/zh/WebRTC.md +1 -1
- package/doc/zh/examples/MobileApp.md +671 -0
- package/doc/zh/examples/README.md +2 -0
- package/doc/zh/examples/TrayApp.md +980 -0
- 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
|
+
-->
|