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.
- package/README.md +50 -30
- package/README.zh-CN.md +130 -0
- package/dist/cjs/exports/QueueComponent.d.ts +1 -0
- package/dist/cjs/exports/QueueComponent.js +20 -0
- package/dist/esm/exports/QueueComponent.js +1 -0
- package/dist/types/exports/QueueComponent.d.ts +1 -0
- package/doc/en/Application.md +107 -0
- package/doc/en/CLI.md +274 -0
- package/doc/en/Cache.md +379 -0
- package/doc/en/Command.md +110 -0
- package/doc/en/ContainerdIntegration.md +17 -16
- package/doc/en/Controllers.md +256 -0
- package/doc/en/DBus.md +291 -0
- package/doc/en/DTOValidation.md +72 -43
- package/doc/en/Database.md +188 -0
- package/doc/en/DatabaseDrivers.md +287 -0
- package/doc/en/Decorator.md +52 -40
- package/doc/en/DockerIntegration.md +7 -6
- package/doc/en/Entities.md +240 -0
- package/doc/en/Entrypoint.md +216 -0
- package/doc/en/Exceptions.md +174 -0
- package/doc/en/GRPC.md +251 -0
- package/doc/en/GettingStarted.md +165 -0
- package/doc/en/HTTP.md +507 -0
- package/doc/en/HelperFunctions.md +11 -11
- package/doc/en/I18n.md +119 -115
- package/doc/en/Injection.md +144 -0
- package/doc/en/Installers.md +280 -0
- package/doc/en/Logger.md +206 -0
- package/doc/en/MCP.md +293 -0
- package/doc/en/Middlewares.md +213 -0
- package/doc/en/Migrations.md +202 -0
- package/doc/en/MongoEmbedded.md +149 -0
- package/doc/en/Monitor.md +164 -0
- package/doc/en/Mutex.md +27 -25
- package/doc/en/NATS.md +246 -0
- package/doc/en/NativeAddons.md +212 -0
- package/doc/en/Objects.md +143 -0
- package/doc/en/Overview.md +62 -85
- package/doc/en/Packaging.md +193 -0
- package/doc/en/ProjectStructure.md +124 -0
- package/doc/en/Queue.md +269 -0
- package/doc/en/Schedules.md +217 -0
- package/doc/en/Security.md +10 -9
- package/doc/en/ServiceCalls.md +504 -0
- package/doc/en/Time.md +19 -20
- package/doc/en/UpgradingFrom1.md +435 -0
- package/doc/en/UpgradingFrom2.md +315 -0
- package/doc/en/Workspaces.md +189 -0
- package/doc/en/examples/BackgroundJobs.md +677 -0
- package/doc/en/examples/CommandLineTool.md +664 -0
- package/doc/en/examples/DesktopApp.md +809 -0
- package/doc/en/examples/DeviceService.md +607 -0
- package/doc/en/examples/MCPServer.md +653 -0
- package/doc/en/examples/README.md +16 -0
- package/doc/en/examples/RestAPI.md +490 -0
- package/doc/en/examples/ServiceCalls.md +829 -0
- package/doc/en/examples/WebApp.md +568 -0
- package/doc/zh/Application.md +75 -0
- package/doc/zh/CLI.md +250 -0
- package/doc/zh/Cache.md +328 -0
- package/doc/zh/Command.md +78 -0
- package/doc/zh/ContainerdIntegration.md +9 -9
- package/doc/zh/Controllers.md +228 -0
- package/doc/zh/DBus.md +257 -0
- package/doc/zh/DTOValidation.md +49 -19
- package/doc/zh/Database.md +175 -0
- package/doc/zh/DatabaseDrivers.md +261 -0
- package/doc/zh/Decorator.md +25 -18
- package/doc/zh/DockerIntegration.md +6 -6
- package/doc/zh/Entities.md +224 -0
- package/doc/zh/Entrypoint.md +193 -0
- package/doc/zh/Exceptions.md +158 -0
- package/doc/zh/GRPC.md +226 -0
- package/doc/zh/GettingStarted.md +148 -0
- package/doc/zh/HTTP.md +445 -0
- package/doc/zh/HelperFunctions.md +2 -2
- package/doc/zh/I18n.md +26 -26
- package/doc/zh/Injection.md +129 -0
- package/doc/zh/Installers.md +232 -0
- package/doc/zh/Logger.md +188 -0
- package/doc/zh/MCP.md +265 -0
- package/doc/zh/Middlewares.md +198 -0
- package/doc/zh/Migrations.md +178 -0
- package/doc/zh/MongoEmbedded.md +118 -0
- package/doc/zh/Monitor.md +145 -0
- package/doc/zh/Mutex.md +10 -10
- package/doc/zh/NATS.md +207 -0
- package/doc/zh/NativeAddons.md +180 -0
- package/doc/zh/Objects.md +119 -0
- package/doc/zh/Overview.md +46 -71
- package/doc/zh/Packaging.md +154 -0
- package/doc/zh/ProjectStructure.md +102 -0
- package/doc/zh/Queue.md +214 -0
- package/doc/zh/Schedules.md +193 -0
- package/doc/zh/Security.md +6 -6
- package/doc/zh/ServiceCalls.md +459 -0
- package/doc/zh/Time.md +2 -2
- package/doc/zh/UpgradingFrom1.md +347 -0
- package/doc/zh/UpgradingFrom2.md +230 -0
- package/doc/zh/Workspaces.md +150 -0
- package/doc/zh/examples/BackgroundJobs.md +618 -0
- package/doc/zh/examples/CommandLineTool.md +618 -0
- package/doc/zh/examples/DesktopApp.md +748 -0
- package/doc/zh/examples/DeviceService.md +546 -0
- package/doc/zh/examples/MCPServer.md +604 -0
- package/doc/zh/examples/README.md +14 -0
- package/doc/zh/examples/RestAPI.md +458 -0
- package/doc/zh/examples/ServiceCalls.md +765 -0
- package/doc/zh/examples/WebApp.md +520 -0
- package/package.json +46 -17
- package/doc/en/ActionPattern.md +0 -408
- package/doc/en/Core.md +0 -312
- package/doc/en/ORMIntegration.md +0 -341
- package/doc/zh/ActionPattern.md +0 -334
- package/doc/zh/Core.md +0 -256
- package/doc/zh/ORMIntegration.md +0 -319
package/doc/en/CLI.md
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# Lakutata Command Line
|
|
2
|
+
|
|
3
|
+
The command line adapter turns the controller actions marked with `@CLIAction` into the commands of a command line
|
|
4
|
+
tool. It is built on [Commander](https://github.com/tj/commander.js). Install `commander` (version 12 to 15) next to
|
|
5
|
+
`lakutata`, and `@inquirer/prompts` (version 7 or 8) if the tool should ask for missing values:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install commander @inquirer/prompts
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## 1. A first tool
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import {Application, DTO} from 'lakutata'
|
|
15
|
+
import {buildEntrypoints, Controller} from 'lakutata/com/entrypoint'
|
|
16
|
+
import {CLIAction} from 'lakutata/decorator/ctrl'
|
|
17
|
+
import {Expect} from 'lakutata/decorator/dto'
|
|
18
|
+
import {commanderCLIEntrypoint} from 'lakutata/adapter/commander'
|
|
19
|
+
|
|
20
|
+
class GreetDTO extends DTO {
|
|
21
|
+
@Expect(DTO.String().required().description('The name to greet'))
|
|
22
|
+
public name: string
|
|
23
|
+
|
|
24
|
+
@Expect(DTO.Number().integer().min(1).default(1).description('How many times'))
|
|
25
|
+
public times: number
|
|
26
|
+
|
|
27
|
+
@Expect(DTO.Boolean().default(false).description('Greet in capitals'))
|
|
28
|
+
public loud: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class ToolController extends Controller {
|
|
32
|
+
@CLIAction('greet <name>', GreetDTO, {description: 'Greet someone'})
|
|
33
|
+
public async greet(input: GreetDTO): Promise<string> {
|
|
34
|
+
const text: string = `Hello, ${input.name}`
|
|
35
|
+
return Array(input.times).fill(input.loud ? text.toUpperCase() : text).join('\n')
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
Application.run(() => ({
|
|
40
|
+
id: 'tool.app',
|
|
41
|
+
name: 'Tool',
|
|
42
|
+
components: {
|
|
43
|
+
entrypoint: buildEntrypoints({
|
|
44
|
+
controllers: [ToolController],
|
|
45
|
+
cli: commanderCLIEntrypoint({bin: 'tool', description: 'A small tool', version: '1.0.0'})
|
|
46
|
+
})
|
|
47
|
+
},
|
|
48
|
+
bootstrap: ['entrypoint']
|
|
49
|
+
}))
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```text
|
|
53
|
+
$ node dist/index.js greet Ada --times 2 --loud
|
|
54
|
+
HELLO, ADA
|
|
55
|
+
HELLO, ADA
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
What happens:
|
|
59
|
+
|
|
60
|
+
1. The application launches. The entrypoint must be listed in `bootstrap`: otherwise nothing creates it and no command
|
|
61
|
+
runs.
|
|
62
|
+
2. Once the application has launched (every object in `bootstrap` is created and the other adapters have started), the
|
|
63
|
+
adapter reads the command line and runs the matching action.
|
|
64
|
+
3. The result goes to stdout, an error to stderr.
|
|
65
|
+
4. The application stops and the process exits with code 0 if the command succeeded, 1 if it failed.
|
|
66
|
+
|
|
67
|
+
`bin` is the name of the program in the help. Without it, the adapter takes the name of the script file, which is
|
|
68
|
+
`index` for `node dist/index.js`.
|
|
69
|
+
|
|
70
|
+
## 2. Commands and arguments
|
|
71
|
+
|
|
72
|
+
`@CLIAction(command, DTO?, options?)` (from `lakutata/decorator/ctrl`) declares a command. The `command` string is the
|
|
73
|
+
name of the command followed by its arguments, in the syntax of Commander:
|
|
74
|
+
|
|
75
|
+
| Syntax | Meaning |
|
|
76
|
+
|---|---|
|
|
77
|
+
| `<name>` | A required argument |
|
|
78
|
+
| `[name]` | An optional argument |
|
|
79
|
+
| `<files...>` | One or more arguments, collected in an array (`[files...]`: zero or more) |
|
|
80
|
+
|
|
81
|
+
Each argument goes to the DTO property of the same name, converted to the type of the property. Extra arguments are an
|
|
82
|
+
error.
|
|
83
|
+
|
|
84
|
+
**Groups.** The words before the name of the command are groups. `@CLIAction('file copy <files...>', CopyDTO)` is the
|
|
85
|
+
command `copy` of the group `file`, run as `tool file copy a.txt b.txt`. Groups can be nested (`'pack linux deb'`). The
|
|
86
|
+
`groups` option of the adapter describes them in the help, and `tool file --help` lists the commands of a group. The
|
|
87
|
+
help of a tool with the command `greet` and the group `file`:
|
|
88
|
+
|
|
89
|
+
```text
|
|
90
|
+
$ tool --help
|
|
91
|
+
Usage: tool [options] [command]
|
|
92
|
+
|
|
93
|
+
A small tool
|
|
94
|
+
|
|
95
|
+
Options:
|
|
96
|
+
-V, --version output the version number
|
|
97
|
+
-h, --help display help for command
|
|
98
|
+
|
|
99
|
+
Commands:
|
|
100
|
+
greet [options] <name> Greet someone
|
|
101
|
+
file Work on files
|
|
102
|
+
help [command] display help for command
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**Help text.** The `description` action option is the line of the command in the help. Without it, the help shows the
|
|
106
|
+
name of the action (`ToolController:greet`).
|
|
107
|
+
|
|
108
|
+
**Prefixes.** `@Prefix({command: 'user'})` on a controller joins the prefix to each of its commands with a colon:
|
|
109
|
+
`@CLIAction('get <id>')` becomes `tool user:get 42`. The prefix is part of the name of the command, not a group.
|
|
110
|
+
|
|
111
|
+
## 3. Options
|
|
112
|
+
|
|
113
|
+
The other properties of the DTO are options. An option is named after its property in kebab case (`keepTimes` gives
|
|
114
|
+
`--keep-times`), and the type of the property decides its form:
|
|
115
|
+
|
|
116
|
+
| Type of the property | Option | Example |
|
|
117
|
+
|---|---|---|
|
|
118
|
+
| boolean | A flag | `--loud` |
|
|
119
|
+
| boolean whose default is `true` | A flag and its negation | `--keep-times`, `--no-keep-times` |
|
|
120
|
+
| `DTO.Alternatives(DTO.Boolean(), …)` | A flag with an optional value | `--color`, `--color always` |
|
|
121
|
+
| number | A number | `--times 2` |
|
|
122
|
+
| array | One or more values, also collected from repeated options | `--ports 80 443` |
|
|
123
|
+
| object | A JSON value | `--filter '{"active":true}'` |
|
|
124
|
+
| anything else | A string | `--mode fast` |
|
|
125
|
+
|
|
126
|
+
The help shows the `.description()` of each property, its default and its allowed values (`.valid()`). A value outside
|
|
127
|
+
the allowed list, or text where a number is expected, is refused before the action runs:
|
|
128
|
+
|
|
129
|
+
```text
|
|
130
|
+
$ tool file copy --help
|
|
131
|
+
Usage: tool file copy [options] <files...>
|
|
132
|
+
|
|
133
|
+
Copy files
|
|
134
|
+
|
|
135
|
+
Options:
|
|
136
|
+
--mode <value> The copy mode (choices: "fast", "safe", default: "safe")
|
|
137
|
+
--keep-times Keep the timestamps (default: true)
|
|
138
|
+
--no-keep-times Turn off --keep-times
|
|
139
|
+
--ports <values...> The ports
|
|
140
|
+
-h, --help display help for command
|
|
141
|
+
|
|
142
|
+
$ tool file copy a.txt --mode slow
|
|
143
|
+
error: option '--mode <value>' argument 'slow' is invalid. Allowed choices are "fast", "safe".
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
- A required property without a default must be given, unless the tool asks for it (see section 5).
|
|
147
|
+
- The options have no short form such as `-m`.
|
|
148
|
+
- A command without a DTO accepts no option, and its arguments reach the action as strings.
|
|
149
|
+
|
|
150
|
+
The DTO then validates the input as for any action: its defaults are applied and the unknown keys removed (see
|
|
151
|
+
[DTO Validation](DTOValidation.md)).
|
|
152
|
+
|
|
153
|
+
## 4. Output and exit codes
|
|
154
|
+
|
|
155
|
+
The value returned by the action is written to stdout:
|
|
156
|
+
|
|
157
|
+
| Value returned | Output |
|
|
158
|
+
|---|---|
|
|
159
|
+
| A string | The string, with a final line break added if it has none |
|
|
160
|
+
| `null`, `undefined` | Nothing |
|
|
161
|
+
| A `Uint8Array` (a `Buffer`) | The bytes as they are |
|
|
162
|
+
| Any other value | Indented JSON |
|
|
163
|
+
| `Result.file(path)` (or a `Buffer`, or a stream) | The content of the file |
|
|
164
|
+
| `Result.events(producer)` | The data of each event, as it is sent |
|
|
165
|
+
| `Result.status(status, data)` | The data only |
|
|
166
|
+
|
|
167
|
+
`Result` comes from `lakutata/com/entrypoint`. The `format` option replaces this formatting: it receives each result
|
|
168
|
+
(and each event) and returns a string, bytes, or `undefined` to write nothing.
|
|
169
|
+
|
|
170
|
+
Where the messages go, and the exit code:
|
|
171
|
+
|
|
172
|
+
| Situation | Output | Exit code |
|
|
173
|
+
|---|---|---|
|
|
174
|
+
| The command succeeded | The result, on stdout | 0 |
|
|
175
|
+
| The action, a middleware, the validation or the access control failed | `error: <message>` on stderr | 1 |
|
|
176
|
+
| An unknown command, a missing argument, an invalid value, extra arguments | Commander's message on stderr | 1 |
|
|
177
|
+
| `--help`, `--version` | stdout | 0 |
|
|
178
|
+
| No command at all | The help on stderr | 1 |
|
|
179
|
+
|
|
180
|
+
```text
|
|
181
|
+
$ tool greet
|
|
182
|
+
error: missing required argument 'name'
|
|
183
|
+
$ tool greet Ada --times x
|
|
184
|
+
error: option '--times <value>' argument 'x' is invalid. Not a number.
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Inside the action, `this.context` is a `CLIContext`: its `command` is the command as declared, with its prefix
|
|
188
|
+
(`'user:get <id>'`), and its `data` holds the arguments and options read from the command line, before validation.
|
|
189
|
+
|
|
190
|
+
## 5. Prompts
|
|
191
|
+
|
|
192
|
+
When the tool runs in a terminal and `@inquirer/prompts` is installed, it asks for the missing values instead of
|
|
193
|
+
failing. It asks for each required property that has no value and no default, one after another. A missing required
|
|
194
|
+
argument (`<name>`) is not asked for: it is an error.
|
|
195
|
+
|
|
196
|
+
| Property | Prompt |
|
|
197
|
+
|---|---|
|
|
198
|
+
| boolean | Yes or no |
|
|
199
|
+
| With allowed values (`.valid()`) | A list to choose from |
|
|
200
|
+
| An array of allowed values | A list of check boxes |
|
|
201
|
+
| number | A number |
|
|
202
|
+
| Named like a password, a secret, a token, an API key or a credential | A hidden input, shown as `*` |
|
|
203
|
+
| Anything else | A text; an array as values separated by commas, an object as JSON |
|
|
204
|
+
|
|
205
|
+
Each answer is checked against the schema of its property at once: an invalid answer shows the error and the question
|
|
206
|
+
is asked again. The question is the `.description()` of the property, followed by its name.
|
|
207
|
+
|
|
208
|
+
The `interactive` option decides when the tool asks:
|
|
209
|
+
|
|
210
|
+
- `'auto'` (the default): when stdin and stdout are both a terminal. When the input or the output is piped, as in a
|
|
211
|
+
script, a missing value is an error instead of a question.
|
|
212
|
+
- `true`: always, as long as `@inquirer/prompts` is installed.
|
|
213
|
+
- `false`: never.
|
|
214
|
+
|
|
215
|
+
## 6. The options of the adapter
|
|
216
|
+
|
|
217
|
+
| Option | Default | Meaning |
|
|
218
|
+
|---|---|---|
|
|
219
|
+
| `bin` | The name of the script file | The name of the program in the help |
|
|
220
|
+
| `description` | | The description of the program in the help |
|
|
221
|
+
| `version` | | Adds `-V, --version`, which prints it |
|
|
222
|
+
| `groups` | | The descriptions of the groups: `{file: 'Work on files', 'pack linux': 'Linux packages'}` |
|
|
223
|
+
| `argv` | `process.argv.slice(2)` | The arguments to read |
|
|
224
|
+
| `interactive` | `'auto'` | When to ask for the missing values (see section 5) |
|
|
225
|
+
| `stdin`, `stdout`, `stderr` | The streams of the process | Where the prompts read, and where the results and the errors are written |
|
|
226
|
+
| `format` | See section 4 | Turns a result into the text or the bytes to write |
|
|
227
|
+
| `noCommand` | `'help'` | Without arguments: `'help'` writes the help and exits with code 1, `'ignore'` runs nothing and leaves the application running |
|
|
228
|
+
| `exit` | `'application'` | After the command: `'application'` stops the application and exits with the code of the command, `'process'` only sets `process.exitCode`, `'none'` does nothing, and a function receives the code |
|
|
229
|
+
| `configure` | | `(program) => void`, called with the Commander program once the commands are added, before the arguments are read: global options, hooks |
|
|
230
|
+
| `translate` | | Translates the help and the prompts (see [I18n](I18n.md#help-and-prompts-of-a-command-line-tool)) |
|
|
231
|
+
| `middlewares` | | The middlewares of the commands of this adapter, run after the ones of the entrypoint |
|
|
232
|
+
| `name` | | The name of the adapter, to tell several apart |
|
|
233
|
+
|
|
234
|
+
## 7. Commands in a server application
|
|
235
|
+
|
|
236
|
+
An application can serve HTTP and also provide commands. By default, though, the command line adapter stops the
|
|
237
|
+
application after its command, and without arguments it writes the help and exits. To start the server when the
|
|
238
|
+
application runs without arguments, and to run a command otherwise, set `noCommand: 'ignore'`:
|
|
239
|
+
|
|
240
|
+
```typescript
|
|
241
|
+
import {Application} from 'lakutata'
|
|
242
|
+
import {buildEntrypoints, Controller} from 'lakutata/com/entrypoint'
|
|
243
|
+
import {CLIAction, GET} from 'lakutata/decorator/ctrl'
|
|
244
|
+
import {commanderCLIEntrypoint} from 'lakutata/adapter/commander'
|
|
245
|
+
import {expressHTTPEntrypoint} from 'lakutata/adapter/express'
|
|
246
|
+
|
|
247
|
+
class AppController extends Controller {
|
|
248
|
+
@GET('/hello')
|
|
249
|
+
public async hello(): Promise<string> {
|
|
250
|
+
return 'Hello'
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
@CLIAction('migrate', {description: 'Migrate the database'})
|
|
254
|
+
public async migrate(): Promise<string> {
|
|
255
|
+
return 'Migrated'
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
Application.run(() => ({
|
|
260
|
+
id: 'shop.app',
|
|
261
|
+
name: 'Shop',
|
|
262
|
+
components: {
|
|
263
|
+
entrypoint: buildEntrypoints({
|
|
264
|
+
controllers: [AppController],
|
|
265
|
+
cli: commanderCLIEntrypoint({bin: 'shop', noCommand: 'ignore'}),
|
|
266
|
+
http: expressHTTPEntrypoint({port: 8080})
|
|
267
|
+
})
|
|
268
|
+
},
|
|
269
|
+
bootstrap: ['entrypoint']
|
|
270
|
+
}))
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
`node dist/index.js` starts the server and keeps running. `node dist/index.js migrate` runs the command once the server
|
|
274
|
+
has started, then stops the application.
|
package/doc/en/Cache.md
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
# Lakutata Cache
|
|
2
|
+
|
|
3
|
+
The **Cacher component** (`lakutata/com/cacher`, package `@lakutata/cache`) is a key-value cache whose entries expire.
|
|
4
|
+
It keeps them in the memory of the process, in a file, or on a server that the instances of the application share:
|
|
5
|
+
Redis, Memcache, MongoDB, SQLite, PostgreSQL or MySQL. Several stores make tiers, the fastest first. Its `wrap()` method
|
|
6
|
+
returns a cached value, or computes it and caches it when it is missing.
|
|
7
|
+
|
|
8
|
+
It is not the query cache of the databases (the `cache` option of a `Database`, see [Database](Database.md)).
|
|
9
|
+
|
|
10
|
+
## 1. A first cache
|
|
11
|
+
|
|
12
|
+
```typescript
|
|
13
|
+
import {Application, Component} from 'lakutata'
|
|
14
|
+
import {Inject} from 'lakutata/decorator/di'
|
|
15
|
+
import {buildCacherOptions, type Cacher} from 'lakutata/com/cacher'
|
|
16
|
+
|
|
17
|
+
type Product = { id: number, name: string }
|
|
18
|
+
|
|
19
|
+
class Catalog extends Component {
|
|
20
|
+
@Inject('cache')
|
|
21
|
+
protected readonly cache: Cacher
|
|
22
|
+
|
|
23
|
+
//Loaded once, then read from the cache until the entry expires
|
|
24
|
+
public async product(id: number): Promise<Product> {
|
|
25
|
+
return await this.cache.wrap(`product:${id}`, (): Promise<Product> => this.load(id))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
public async rename(id: number, name: string): Promise<void> {
|
|
29
|
+
//Update the database here, then delete the cached copy
|
|
30
|
+
await this.cache.del(`product:${id}`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
protected async load(id: number): Promise<Product> {
|
|
34
|
+
return {id: id, name: `Product ${id}`}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
Application
|
|
39
|
+
.run(() => ({
|
|
40
|
+
id: 'catalog.app',
|
|
41
|
+
name: 'Catalog',
|
|
42
|
+
components: {
|
|
43
|
+
//In memory; the entries expire after 5 minutes
|
|
44
|
+
cache: buildCacherOptions({ttl: 300000}),
|
|
45
|
+
catalog: {class: Catalog}
|
|
46
|
+
}
|
|
47
|
+
}))
|
|
48
|
+
.onLaunched(async (app: Application): Promise<void> => {
|
|
49
|
+
const catalog: Catalog = await app.getObject<Catalog>('catalog')
|
|
50
|
+
console.log(await catalog.product(1))
|
|
51
|
+
const cache: Cacher = await app.getObject<Cacher>('cache')
|
|
52
|
+
//This entry expires after 30 seconds
|
|
53
|
+
await cache.set('greeting', 'Hello', 30000)
|
|
54
|
+
console.log(await cache.get<string>('greeting'))
|
|
55
|
+
})
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- `buildCacherOptions` declares the component under the name of your choice; the other objects inject it by that name
|
|
59
|
+
(`@Inject('cache')`) or get it with `getObject('cache')`. The whole application shares one instance.
|
|
60
|
+
- Without `stores`, the cache keeps its entries in the memory of the process.
|
|
61
|
+
- The durations are numbers of milliseconds.
|
|
62
|
+
- The queues can keep the results of their messages in a cache (see [Queue](Queue.md), section 1).
|
|
63
|
+
|
|
64
|
+
## 2. Options
|
|
65
|
+
|
|
66
|
+
| Option | Default | Meaning |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `stores` | one memory store | The store, or the list of stores with the fastest first (sections 5 and 6) |
|
|
69
|
+
| `ttl` | none: the entries never expire | How long an entry is kept when a write gives no TTL, in milliseconds (a positive integer) |
|
|
70
|
+
| `refreshThreshold` | none | The default threshold of the background refresh of `wrap()`, in milliseconds (section 4) |
|
|
71
|
+
| `refreshAllStores` | `false` | Whether a background refresh writes the new value to all the stores (section 5) |
|
|
72
|
+
| `nonBlocking` | `false` | Whether the writes and the deletions return without waiting for the stores (section 5) |
|
|
73
|
+
|
|
74
|
+
The option `cacheId` is accepted and not used.
|
|
75
|
+
|
|
76
|
+
- Invalid options fail the application's start with `InvalidObjectOptionsException`.
|
|
77
|
+
- The stores connect when the component is created. When it is listed in `bootstrap`, it is created while the
|
|
78
|
+
application starts, and a store that cannot connect fails the start. Otherwise it is created the first time it is
|
|
79
|
+
used, and that `getObject()` or that injection fails with the store's error.
|
|
80
|
+
- When the application stops, the cache waits for its background refreshes, then disconnects its stores (the file
|
|
81
|
+
store saves its last changes first).
|
|
82
|
+
|
|
83
|
+
## 3. Reading and writing
|
|
84
|
+
|
|
85
|
+
| Method | What it does | Returns |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| `set(key, value, ttl?)` | Caches a value in all the stores, replacing the previous one | The value |
|
|
88
|
+
| `get(key)` | Reads a value | The value; `undefined` when the key is missing or expired |
|
|
89
|
+
| `multipleSet([{key, value, ttl?}])` | Caches several values | The entries written |
|
|
90
|
+
| `multipleGet(keys)` | Reads several values | The values, in the order of the keys |
|
|
91
|
+
| `getTTL(key)` | Reads when the entry expires | A time in milliseconds since the epoch, or `-1` |
|
|
92
|
+
| `wrap(key, fn, options?)` | Reads a value, or computes it and caches it (section 4) | The value |
|
|
93
|
+
| `del(key)` | Deletes a key from all the stores | `true` |
|
|
94
|
+
| `multipleDel(keys)` | Deletes several keys | `true` |
|
|
95
|
+
| `clear()` | Deletes all the entries of each store, or those of its namespace (section 6) | `true` |
|
|
96
|
+
|
|
97
|
+
- **TTL.** The TTL of a write replaces the `ttl` option, and `0` makes the entry never expire. The `ttl` option itself
|
|
98
|
+
cannot be `0`: leave it out when the entries should never expire. A TTL is a number, never a text such as `'1h'`.
|
|
99
|
+
- **`get()` never fails.** A store that fails to read counts as a miss, and its error is emitted as an `'error'`
|
|
100
|
+
event (section 8). A cached `undefined` cannot be told apart from a miss.
|
|
101
|
+
- **`getTTL()` returns a point in time**, not the time left: the time left is `expires - Date.now()`. It returns `-1`
|
|
102
|
+
when the key is missing or expired, and when the entry never expires.
|
|
103
|
+
- **`multipleSet()` and `multipleDel()` emit no `'set'` or `'del'` event**, unlike `set()`, `wrap()` and `del()`.
|
|
104
|
+
- `del()` and `multipleDel()` return `true` even when the key did not exist.
|
|
105
|
+
- A store that fails to write or delete fails the call, unless `nonBlocking` is set (section 5).
|
|
106
|
+
|
|
107
|
+
## 4. `wrap()`
|
|
108
|
+
|
|
109
|
+
`wrap(key, fn, options)` returns the cached value of the key. When the key is missing or expired, it calls `fn`, caches
|
|
110
|
+
its result and returns it. It is the usual way to cache the result of a query or of a call to another service:
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
import type {Cacher} from 'lakutata/com/cacher'
|
|
114
|
+
|
|
115
|
+
declare const cache: Cacher
|
|
116
|
+
declare function loadRates(): Promise<Record<string, number>>
|
|
117
|
+
|
|
118
|
+
//Loaded at most once per hour; in its last 5 minutes, returned while it is loaded again in the background
|
|
119
|
+
const rates: Record<string, number> = await cache.wrap('rates', loadRates, {ttl: 3600000, refreshThreshold: 300000})
|
|
120
|
+
|
|
121
|
+
type User = { id: number, guest: boolean }
|
|
122
|
+
//A TTL that depends on the value
|
|
123
|
+
const user: User = await cache.wrap('user:1', async (): Promise<User> => ({id: 1, guest: true}), {
|
|
124
|
+
ttl: (value: User): number => value.guest ? 60000 : 3600000
|
|
125
|
+
})
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
- The third argument is a TTL (`wrap(key, fn, 60000)`) or `{ttl, refreshThreshold}`, each of them a number or a
|
|
129
|
+
function of the value. They default to the `ttl` and `refreshThreshold` options.
|
|
130
|
+
- While a `wrap()` of a key is in progress, the other calls on that key share it and its result. This holds within one
|
|
131
|
+
component: two instances of the application may both compute the value. Use a lock (see [Mutex](Mutex.md)) when it
|
|
132
|
+
must be computed only once.
|
|
133
|
+
- When `fn` fails, `wrap()` fails with its error, and nothing is cached. A result of `undefined` is cached like any
|
|
134
|
+
other value.
|
|
135
|
+
- **Background refresh.** When the cached value has less time left than the refresh threshold, `wrap()` returns it at
|
|
136
|
+
once and calls `fn` in the background, one refresh per key at a time. The new value replaces the old one, and a
|
|
137
|
+
`'refresh'` event reports it; when `fn` fails, the event reports the error and the old value stays. An entry that
|
|
138
|
+
never expires is never refreshed, and a threshold higher than the TTL refreshes the value on every call.
|
|
139
|
+
- With several stores, a value found in a lower store is copied to the stores above it, with the time it has left
|
|
140
|
+
(section 5).
|
|
141
|
+
|
|
142
|
+
## 5. Several stores
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
import {Application} from 'lakutata'
|
|
146
|
+
import {buildCacherOptions} from 'lakutata/com/cacher'
|
|
147
|
+
|
|
148
|
+
Application.run(() => ({
|
|
149
|
+
id: 'tiers.app',
|
|
150
|
+
name: 'Tiers',
|
|
151
|
+
components: {
|
|
152
|
+
cache: buildCacherOptions({
|
|
153
|
+
//Each instance keeps its copies in memory, in front of the Redis that all the instances share
|
|
154
|
+
stores: [
|
|
155
|
+
{type: 'memory'},
|
|
156
|
+
{type: 'redis', host: '127.0.0.1', namespace: 'tiers'}
|
|
157
|
+
],
|
|
158
|
+
ttl: 60000
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
}))
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
- A read asks the stores in order and returns the first entry found. `multipleGet()` asks the next store only for the
|
|
165
|
+
keys that are still missing.
|
|
166
|
+
- A write or a deletion goes to all the stores at once.
|
|
167
|
+
- Only `wrap()` copies an entry found in a lower store to the stores above it; `get()` does not.
|
|
168
|
+
- A background refresh writes the new value to the store that held the old one and to the stores above it, or to all
|
|
169
|
+
the stores with `refreshAllStores: true`.
|
|
170
|
+
- The memory of an instance keeps its copy until the entry expires, even when another instance changes or deletes the
|
|
171
|
+
key. Keep the TTL short when every instance must see the changes quickly.
|
|
172
|
+
- **`nonBlocking: true`.** The writes and the deletions return at once, without waiting for the stores, and never fail:
|
|
173
|
+
their failures are emitted as events (section 8). With several stores, a read asks all of them at once and returns
|
|
174
|
+
the first entry found, which may not be the first store's.
|
|
175
|
+
- All the stores connect when the cache is created. When one of them fails, the others disconnect, and the creation
|
|
176
|
+
fails with its error.
|
|
177
|
+
|
|
178
|
+
## 6. The stores
|
|
179
|
+
|
|
180
|
+
| `type` | Where the entries are | Install |
|
|
181
|
+
|---|---|---|
|
|
182
|
+
| `'memory'` | In the memory of the process (the store of a cache without `stores`) | Nothing |
|
|
183
|
+
| `'file'` | In memory, saved to a JSON file | Nothing |
|
|
184
|
+
| `'redis'` | On Redis or Valkey | `redis` (4 or 5) |
|
|
185
|
+
| `'memcache'` | On memcached | `memjs` |
|
|
186
|
+
| `'mongo'` | In a MongoDB collection | `mongodb` |
|
|
187
|
+
| `'sqlite'` | In a table of a SQLite file | `better-sqlite3` (12); nothing on Bun |
|
|
188
|
+
| `'postgres'` | In a PostgreSQL table | `pg` |
|
|
189
|
+
| `'mysql'` | In a MySQL or MariaDB table | `mysql2` |
|
|
190
|
+
|
|
191
|
+
- A store whose package is not installed fails the cache's creation with `CacheDriverNotFoundException`; its message
|
|
192
|
+
names the package to install.
|
|
193
|
+
- Every store but the memory one keeps the values as JSON (section 7).
|
|
194
|
+
- Every store but the memory one takes a `namespace`, which keeps apart the applications that share a server or a
|
|
195
|
+
table: `clear()` then deletes only the entries of the namespace. Without a namespace, `clear()` deletes all the
|
|
196
|
+
entries of the store (for Redis and Memcache, see below).
|
|
197
|
+
- In the SQLite, PostgreSQL and MySQL stores, nothing deletes the expired rows in the background: an expired row stays
|
|
198
|
+
in the table until it is read (it is then deleted), overwritten or cleared.
|
|
199
|
+
|
|
200
|
+
### Memory
|
|
201
|
+
|
|
202
|
+
| Option | Default | Meaning |
|
|
203
|
+
|---|---|---|
|
|
204
|
+
| `sweepInterval` | `60000` | How often the expired entries are removed, in milliseconds (an expired entry is a miss anyway) |
|
|
205
|
+
|
|
206
|
+
The values are kept as they are, not copied (section 7). Each process has its own entries, lost when it ends.
|
|
207
|
+
|
|
208
|
+
### File
|
|
209
|
+
|
|
210
|
+
| Option | Default | Meaning |
|
|
211
|
+
|---|---|---|
|
|
212
|
+
| `filename` | required | The JSON file, relative to the working directory; it is created with its directories |
|
|
213
|
+
| `writeDelay` | `100` | How long after a change the file is saved, in milliseconds; the changes made meanwhile are saved together |
|
|
214
|
+
| `expiredCheckDelay` | `3600000` | The minimum time between two removals of the expired entries from the file, in milliseconds |
|
|
215
|
+
| `namespace` | none | |
|
|
216
|
+
|
|
217
|
+
- The entries are in memory. The file is read when the cache is created, and each save rewrites all of it (through a
|
|
218
|
+
temporary file), so the store suits small caches. The last changes are saved when the application stops.
|
|
219
|
+
- A file belongs to one process: another process reads it only when its own cache is created, and each save replaces
|
|
220
|
+
the entries that the other processes saved.
|
|
221
|
+
- A file that is not JSON fails the cache's creation. A failed save is emitted as an `'error'` event.
|
|
222
|
+
|
|
223
|
+
### Redis
|
|
224
|
+
|
|
225
|
+
| Option | Default | Meaning |
|
|
226
|
+
|---|---|---|
|
|
227
|
+
| `host` | required | |
|
|
228
|
+
| `port` | `6379` | |
|
|
229
|
+
| `database` | `0` | The index of the database |
|
|
230
|
+
| `username`, `password` | none | |
|
|
231
|
+
| `tls` | `false` | Whether to connect with TLS |
|
|
232
|
+
| `namespace` | none | |
|
|
233
|
+
| `keyPrefixSeparator` | `'::'` | The separator that follows the namespace in the keys |
|
|
234
|
+
| `connectTimeout` | `5000` | How long the first connection may take, in milliseconds |
|
|
235
|
+
| `throwOnConnectError` | `true` | Whether a failed first connection fails the cache's creation |
|
|
236
|
+
| `reconnect` | `true` | Whether to reconnect after the connection is lost (after 100 ms per attempt, 3 seconds at most) |
|
|
237
|
+
| `keepAlive` | the driver's | How long the connection stays idle before the first TCP keep-alive probe, in milliseconds |
|
|
238
|
+
| `useUnlink` | `true` | Whether to delete with `UNLINK` rather than `DEL` |
|
|
239
|
+
| `clearBatchSize` | `1000` | How many keys `clear()` scans and deletes at a time |
|
|
240
|
+
| `noNamespaceAffectsAll` | `false` | Whether `clear()` without a namespace empties the whole database (`FLUSHDB`) |
|
|
241
|
+
|
|
242
|
+
- The keys are `{namespace}::{namespace}:{key}` (the format of lakutata 2.x), or the key itself without a namespace.
|
|
243
|
+
Redis deletes the expired entries itself.
|
|
244
|
+
- Without a namespace, `clear()` deletes the string keys of the database that are outside the namespaces (the keys
|
|
245
|
+
without `::`): the namespaced keys and the keys of the other types (hashes, lists…) stay. With
|
|
246
|
+
`noNamespaceAffectsAll: true`, it empties the database.
|
|
247
|
+
- `clear()` scans the keys by type, which needs Redis 6 or later.
|
|
248
|
+
- While the connection is down, the commands fail at once instead of waiting: the reads are misses, the writes fail.
|
|
249
|
+
With `throwOnConnectError: false`, the cache is created even when Redis is down, and keeps trying to connect.
|
|
250
|
+
- The store uses the package `redis`, or `@redis/client` when `redis` is not installed.
|
|
251
|
+
|
|
252
|
+
### Memcache
|
|
253
|
+
|
|
254
|
+
| Option | Default | Meaning |
|
|
255
|
+
|---|---|---|
|
|
256
|
+
| `host` | required | |
|
|
257
|
+
| `port` | `11211` | |
|
|
258
|
+
| `username`, `password` | none | For a server that requires SASL authentication |
|
|
259
|
+
| `namespace` | none | |
|
|
260
|
+
|
|
261
|
+
- The client connects at its first command: a server that cannot be reached does not fail the cache's creation, but
|
|
262
|
+
its reads are misses and its writes fail.
|
|
263
|
+
- memcached counts the TTLs in seconds: an entry may stay up to two seconds longer on the server, but it is a miss
|
|
264
|
+
once its TTL has passed.
|
|
265
|
+
- `clear()` empties the whole server, whatever the namespace: the entries of the other applications are deleted too.
|
|
266
|
+
|
|
267
|
+
### MongoDB
|
|
268
|
+
|
|
269
|
+
| Option | Default | Meaning |
|
|
270
|
+
|---|---|---|
|
|
271
|
+
| `host`, `database`, `collection` | required | The collection is created when it is missing |
|
|
272
|
+
| `port` | `27017` | |
|
|
273
|
+
| `username`, `password` | none | The user is authenticated against the `admin` database |
|
|
274
|
+
| `namespace` | none | |
|
|
275
|
+
|
|
276
|
+
- One document per entry. When the cache is created, it creates a unique index on the keys, and a TTL index by which
|
|
277
|
+
MongoDB deletes the expired documents, about once a minute; an expired document is a miss before that.
|
|
278
|
+
- A failed connection fails the cache's creation.
|
|
279
|
+
|
|
280
|
+
### SQLite
|
|
281
|
+
|
|
282
|
+
| Option | Default | Meaning |
|
|
283
|
+
|---|---|---|
|
|
284
|
+
| `database` | required | The database file, relative to the working directory, in a directory that exists; or `':memory:'` |
|
|
285
|
+
| `table` | required | The table, created when it is missing |
|
|
286
|
+
| `busyTimeout` | `10000` | How long a statement waits for a database locked by another connection, in milliseconds |
|
|
287
|
+
| `namespace` | none | |
|
|
288
|
+
|
|
289
|
+
- The characters of `table` other than letters, digits and `_` are removed.
|
|
290
|
+
- The statements run synchronously: a statement that waits for a locked database blocks the event loop, up to
|
|
291
|
+
`busyTimeout`.
|
|
292
|
+
- `better-sqlite3` is a native addon built for one runtime: an Electron application needs its build for its version of
|
|
293
|
+
Electron. On Bun, the store uses `bun:sqlite` instead.
|
|
294
|
+
|
|
295
|
+
### PostgreSQL
|
|
296
|
+
|
|
297
|
+
| Option | Default | Meaning |
|
|
298
|
+
|---|---|---|
|
|
299
|
+
| `host`, `database`, `table` | required | |
|
|
300
|
+
| `port` | `5432` | |
|
|
301
|
+
| `schema` | `'public'` | The schema of the table |
|
|
302
|
+
| `username`, `password` | the driver's | Without `username`, the `PGUSER` environment variable, else the user of the process |
|
|
303
|
+
| `maxPoolSize` | `10` | The maximum number of connections |
|
|
304
|
+
| `namespace` | none | |
|
|
305
|
+
|
|
306
|
+
- When the cache is created, it creates the schema and the table when they are missing:
|
|
307
|
+
`(key VARCHAR(255) PRIMARY KEY, value TEXT)`. PostgreSQL folds a plain name to lower case: `table: 'Entries'`
|
|
308
|
+
creates `entries`. A failed connection or creation fails the cache's creation.
|
|
309
|
+
- A key longer than 255 characters, its namespace included, fails its write.
|
|
310
|
+
|
|
311
|
+
### MySQL
|
|
312
|
+
|
|
313
|
+
| Option | Default | Meaning |
|
|
314
|
+
|---|---|---|
|
|
315
|
+
| `host`, `database`, `table` | required | |
|
|
316
|
+
| `port` | `3306` | |
|
|
317
|
+
| `username`, `password` | the driver's | |
|
|
318
|
+
| `namespace` | none | |
|
|
319
|
+
|
|
320
|
+
- When the cache is created, it creates the table when it is missing:
|
|
321
|
+
`(id VARCHAR(255) PRIMARY KEY, value MEDIUMTEXT)`, its keys case sensitive. A failed connection or creation fails
|
|
322
|
+
the cache's creation.
|
|
323
|
+
- A key longer than 255 characters, its namespace included, fails its write, and a value is limited to 16 MB of JSON.
|
|
324
|
+
|
|
325
|
+
## 7. What the stores keep
|
|
326
|
+
|
|
327
|
+
The memory store keeps the values themselves: an object changed after it is cached is changed in the cache too, and a
|
|
328
|
+
read returns the cached object. Copy the objects when this matters.
|
|
329
|
+
|
|
330
|
+
The other stores keep the values as JSON, so some values come back different:
|
|
331
|
+
|
|
332
|
+
| Cached | Read back |
|
|
333
|
+
|---|---|
|
|
334
|
+
| A `Date` | Its ISO string (`'2026-01-02T03:04:05.000Z'`) |
|
|
335
|
+
| An instance of a class | A plain object with its fields, without its methods |
|
|
336
|
+
| A `Map` or a `Set` | `{}` |
|
|
337
|
+
| `NaN`, `Infinity` | `null` |
|
|
338
|
+
| A `Buffer` | A `Buffer` |
|
|
339
|
+
| A `bigint` | The write fails with a `TypeError` |
|
|
340
|
+
|
|
341
|
+
With a memory store in front of another store, the same key can return the value itself or its JSON copy, depending on
|
|
342
|
+
the store that holds it. Cache the values that JSON keeps as they are: plain objects, arrays, strings, numbers and
|
|
343
|
+
booleans.
|
|
344
|
+
|
|
345
|
+
## 8. Events and errors
|
|
346
|
+
|
|
347
|
+
| Event | When | The listener receives |
|
|
348
|
+
|---|---|---|
|
|
349
|
+
| `'set'` | `set()` or `wrap()` wrote a value, or failed to | `{key, value, error?}` |
|
|
350
|
+
| `'del'` | `del()` deleted a key, or failed to | `{key, error?}` |
|
|
351
|
+
| `'clear'` | `clear()` finished | Nothing, or the error |
|
|
352
|
+
| `'refresh'` | A background refresh of `wrap()` finished | `{key, value, error?}`: the new value, or the old one and the error |
|
|
353
|
+
| `'error'` | A store failed and no other event reports it (see below) | The error |
|
|
354
|
+
|
|
355
|
+
```typescript
|
|
356
|
+
import type {Cacher, OnRefreshEventData} from 'lakutata/com/cacher'
|
|
357
|
+
|
|
358
|
+
declare const cache: Cacher
|
|
359
|
+
|
|
360
|
+
cache.on('refresh', (data: OnRefreshEventData): void => {
|
|
361
|
+
if (data.error) console.warn(`Refreshing ${data.key} failed, the cached value is kept`)
|
|
362
|
+
})
|
|
363
|
+
//The failed reads, the lost connections, the failed saves of the file…
|
|
364
|
+
cache.on('error', (error: Error): void => console.error('Cache store error', error))
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
- The `'error'` event reports the failed reads (counted as misses), the failures of `multipleSet()` and
|
|
368
|
+
`multipleDel()` and of the copies made by `wrap()`, the lost connections and the failed saves of the file store.
|
|
369
|
+
Without a listener, these errors are dropped: listen to it to know when a store is failing.
|
|
370
|
+
- The listeners are called asynchronously, after the event is emitted.
|
|
371
|
+
- Without `nonBlocking`, a store that fails to write or delete fails the call with the error of its driver. With
|
|
372
|
+
`nonBlocking`, `'set'` and `'del'` are emitted before the stores are written, and a failure emits a second event that
|
|
373
|
+
carries the error.
|
|
374
|
+
|
|
375
|
+
| Error | When |
|
|
376
|
+
|---|---|
|
|
377
|
+
| `InvalidObjectOptionsException` | The options are invalid, when the application starts |
|
|
378
|
+
| `CacheDriverNotFoundException` (`E_CACHE_DRIVER_NOT_FOUND`) | The package of a store is not installed, when the cache is created |
|
|
379
|
+
| The errors of the drivers | A store cannot connect when the cache is created, or fails a write or a deletion |
|