@orieljs/framework 0.1.0-alpha.6 → 0.1.0-alpha.8
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/NOTICE +4 -0
- package/README.md +10 -1
- package/TRADEMARKS.md +33 -0
- package/dist/{chunk-POEP4B2M.js → chunk-7ZQZYDOC.js} +745 -105
- package/dist/chunk-7ZQZYDOC.js.map +1 -0
- package/dist/{chunk-I63ZFMX6.js → chunk-GVNLZZTN.js} +1 -1
- package/dist/chunk-GVNLZZTN.js.map +1 -0
- package/dist/{chunk-ZOEPKBVF.js → chunk-SPQUX3C7.js} +1347 -428
- package/dist/chunk-SPQUX3C7.js.map +1 -0
- package/dist/{chunk-MFTZQMZM.js → chunk-VOYW3R5J.js} +4 -4
- package/dist/chunk-VOYW3R5J.js.map +1 -0
- package/dist/contracts.d.ts +515 -17
- package/dist/contracts.js +1 -1
- package/dist/index.d-WQpfdIbG.d.ts +630 -0
- package/dist/index.d.ts +964 -152
- package/dist/index.js +4279 -866
- package/dist/index.js.map +1 -1
- package/dist/{jsx-runtime-CBZQYYDS.d.ts → jsx-runtime-6um4Mwfb.d.ts} +96 -5
- package/dist/jsx-runtime.d.ts +2 -2
- package/dist/jsx-runtime.js +3 -3
- package/dist/{testing-BM481Hdz.d.ts → testing-CKwfWJGn.d.ts} +180 -15
- package/dist/testing.d.ts +2 -2
- package/dist/testing.js +3 -3
- package/docs/authentication.md +487 -0
- package/docs/authorization.md +435 -0
- package/docs/cache.md +647 -0
- package/docs/collections.md +2270 -0
- package/docs/configuration.md +334 -0
- package/docs/console.md +651 -0
- package/docs/container.md +430 -0
- package/docs/controllers.md +406 -0
- package/docs/csrf.md +126 -0
- package/docs/database-testing.md +144 -0
- package/docs/database.md +236 -0
- package/docs/deployment.md +276 -0
- package/docs/eloquent-collections.md +218 -0
- package/docs/eloquent-factories.md +255 -0
- package/docs/eloquent-mutators.md +254 -0
- package/docs/eloquent-observers.md +145 -0
- package/docs/eloquent-relationships.md +557 -0
- package/docs/eloquent.md +494 -0
- package/docs/encryption.md +104 -0
- package/docs/errors.md +318 -0
- package/docs/events.md +336 -0
- package/docs/facades.md +278 -0
- package/docs/hashing.md +118 -0
- package/docs/helpers.md +1281 -0
- package/docs/http-tests.md +386 -0
- package/docs/inertia.md +485 -0
- package/docs/installation.md +209 -0
- package/docs/lifecycle.md +234 -0
- package/docs/live-tests.md +226 -0
- package/docs/live.md +910 -0
- package/docs/logging.md +255 -0
- package/docs/mail.md +619 -0
- package/docs/middleware.md +384 -0
- package/docs/migrations.md +416 -0
- package/docs/mocking.md +264 -0
- package/docs/navigation.json +273 -0
- package/docs/notifications.md +225 -0
- package/docs/pagination.md +247 -0
- package/docs/passwords.md +207 -0
- package/docs/providers.md +187 -0
- package/docs/queries.md +600 -0
- package/docs/queues.md +330 -0
- package/docs/requests.md +533 -0
- package/docs/responses.md +444 -0
- package/docs/routing.md +738 -0
- package/docs/scheduling.md +277 -0
- package/docs/seeding.md +122 -0
- package/docs/session.md +409 -0
- package/docs/starter-kits.md +396 -0
- package/docs/storage.md +192 -0
- package/docs/structure.md +264 -0
- package/docs/testing.md +165 -0
- package/docs/time-travel.md +138 -0
- package/docs/validation.md +1792 -0
- package/docs/verification.md +152 -0
- package/docs/views.md +566 -0
- package/docs/vite.md +322 -0
- package/package.json +34 -32
- package/dist/chunk-I63ZFMX6.js.map +0 -1
- package/dist/chunk-MFTZQMZM.js.map +0 -1
- package/dist/chunk-POEP4B2M.js.map +0 -1
- package/dist/chunk-ZOEPKBVF.js.map +0 -1
- package/dist/index.d-BJyJx6U1.d.ts +0 -319
package/docs/cache.md
ADDED
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
# Cache
|
|
2
|
+
|
|
3
|
+
Keep the result of slow work, such as a heavy query or a call to another service, in the cache, and read it back
|
|
4
|
+
quickly on the next request.
|
|
5
|
+
|
|
6
|
+
## Introduction
|
|
7
|
+
|
|
8
|
+
Some of the data your app reads or computes takes a while to get, and stays the same for a time. Store it in a cache
|
|
9
|
+
and later requests read it from there. Oriel's cache has one API over several stores: your database, files, Redis, or
|
|
10
|
+
your app's memory.
|
|
11
|
+
|
|
12
|
+
## Configuration
|
|
13
|
+
|
|
14
|
+
The cache's settings are in `config/cache.ts`. `default` names the store that the `Cache` facade uses, and a new app
|
|
15
|
+
uses the `database` store:
|
|
16
|
+
|
|
17
|
+
```ts title="config/cache.ts"
|
|
18
|
+
import { dirname, join } from 'node:path'
|
|
19
|
+
import { env, Str } from '@orieljs/framework'
|
|
20
|
+
|
|
21
|
+
const basePath = dirname(import.meta.dirname)
|
|
22
|
+
|
|
23
|
+
export default {
|
|
24
|
+
default: env('CACHE_STORE', 'database'),
|
|
25
|
+
stores: {
|
|
26
|
+
array: { driver: 'array' },
|
|
27
|
+
database: { driver: 'database', table: 'cache', lockTable: 'cache_locks' },
|
|
28
|
+
file: { driver: 'file', path: join(basePath, 'storage/framework/cache/data') },
|
|
29
|
+
redis: { driver: 'redis', connection: 'cache' },
|
|
30
|
+
},
|
|
31
|
+
prefix: env('CACHE_PREFIX', Str.slug(String(env('APP_NAME', 'oriel')), '_') + '_cache_'),
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Each store has a driver:
|
|
36
|
+
|
|
37
|
+
- `database`: in the `cache` table of your database.
|
|
38
|
+
- `file`: one file per key, in `storage/framework/cache/data`.
|
|
39
|
+
- `redis`: in Redis.
|
|
40
|
+
- `array`: in memory, in your app's process. Your tests use it.
|
|
41
|
+
- `memory`: in memory too, with an optional `maxItems`: past that number of entries, the least recently used one is
|
|
42
|
+
dropped. A new app's config doesn't list a `memory` store; add one to use it:
|
|
43
|
+
`memory: { driver: 'memory', maxItems: 1000 }`.
|
|
44
|
+
|
|
45
|
+
`prefix` starts every key, so that two apps can share a store: `blog_cache_` for an app called Blog. A store can set
|
|
46
|
+
its own `prefix`.
|
|
47
|
+
|
|
48
|
+
> [!NOTE]
|
|
49
|
+
> Laravel's `array` store is empty at the start of each request. Oriel's process outlives each request, so an `array`
|
|
50
|
+
> or `memory` store keeps its entries for as long as the process runs, and loses them when it restarts. Each process
|
|
51
|
+
> has its own: two servers, or a server and a queue worker, don't share them.
|
|
52
|
+
|
|
53
|
+
### Driver prerequisites
|
|
54
|
+
|
|
55
|
+
#### Database
|
|
56
|
+
|
|
57
|
+
The `database` store needs the `cache` table, and its locks the `cache_locks` table. A new app's
|
|
58
|
+
`0001_01_01_000001_create_cache_table.ts` migration creates both:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
await Schema.create('cache', (t) => {
|
|
62
|
+
t.string('key').primary()
|
|
63
|
+
t.text('value')
|
|
64
|
+
t.integer('expiration')
|
|
65
|
+
})
|
|
66
|
+
await Schema.create('cache_locks', (t) => {
|
|
67
|
+
t.string('key').primary()
|
|
68
|
+
t.string('owner')
|
|
69
|
+
t.integer('expiration')
|
|
70
|
+
})
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
A store's `connection` names the database connection to use, the default one when it has none, and `table` and
|
|
74
|
+
`lockTable` the tables.
|
|
75
|
+
|
|
76
|
+
#### Redis
|
|
77
|
+
|
|
78
|
+
The `redis` store talks to the Redis server of `config/database.ts`: `connection` names an entry of `database.redis`.
|
|
79
|
+
A new app's `cache` entry is its own Redis database, number 1 unless you set `REDIS_CACHE_DB`, so that flushing the
|
|
80
|
+
cache leaves your sessions and queued jobs alone. Oriel's Redis client, `ioredis`, comes with the framework, and the
|
|
81
|
+
connection is closed when your app shuts down.
|
|
82
|
+
|
|
83
|
+
## Cache usage
|
|
84
|
+
|
|
85
|
+
### Obtaining a cache instance
|
|
86
|
+
|
|
87
|
+
The `Cache` facade reaches the default store. Every method is async, so `await` it:
|
|
88
|
+
|
|
89
|
+
```ts title="app/Http/Controllers/UserController.ts"
|
|
90
|
+
import { Cache } from '@orieljs/framework'
|
|
91
|
+
import { Controller } from './Controller'
|
|
92
|
+
|
|
93
|
+
export class UserController extends Controller {
|
|
94
|
+
/** Show a list of all users of the application. */
|
|
95
|
+
async index() {
|
|
96
|
+
const value = await Cache.get('key')
|
|
97
|
+
|
|
98
|
+
return { value }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
#### Accessing multiple cache stores
|
|
104
|
+
|
|
105
|
+
`Cache.store()` returns another store of `config/cache.ts` by its name:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
const value = await Cache.store('file').get('foo')
|
|
109
|
+
|
|
110
|
+
await Cache.store('redis').put('bar', 'baz', 600) // 10 Minutes
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
A name that isn't in `stores` is an error: "Cache store [mongo] is not configured. Add it to config('cache.stores').".
|
|
114
|
+
|
|
115
|
+
### Retrieving items from the cache
|
|
116
|
+
|
|
117
|
+
`get()` returns the value of a key, or `undefined` when the cache has no such key. A second argument is the default,
|
|
118
|
+
returned in its place:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
const value = await Cache.get('key')
|
|
122
|
+
|
|
123
|
+
const valueOrDefault = await Cache.get('key', 'default')
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The default may be a function, which is only called when the key is missing, so a costly default isn't computed for
|
|
127
|
+
nothing:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
const users = await Cache.get('users', () => DB.table('users').get())
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`get()` takes a type for the value, which is a cast, not a check: `Cache.get<string>('name')`.
|
|
134
|
+
|
|
135
|
+
`many()` reads several keys at once, and returns an object with a value, or `undefined`, for each:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
const values = await Cache.many(['name', 'email'])
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
#### Determining item existence
|
|
142
|
+
|
|
143
|
+
`has()` tells you whether a key is in the cache, and `missing()` whether it isn't. A key stored with a `null` value
|
|
144
|
+
is in the cache:
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
if (await Cache.has('key')) {
|
|
148
|
+
// ...
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
> [!NOTE]
|
|
153
|
+
> Laravel's `has()` is `false` for a key whose value is `null`, because `null` is how Laravel reports a miss. Oriel
|
|
154
|
+
> reports a miss as `undefined`, so a stored `null` is a value like any other.
|
|
155
|
+
|
|
156
|
+
#### Incrementing / decrementing values
|
|
157
|
+
|
|
158
|
+
`increment()` and `decrement()` change the number stored under a key, by 1 or by the amount you give, and return the
|
|
159
|
+
new value. A missing key counts as 0, and is then stored with no expiry; a key that has one keeps it:
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
declare const amount: number
|
|
163
|
+
// ---cut---
|
|
164
|
+
// Initialize the value if it does not exist...
|
|
165
|
+
await Cache.add('key', 0, 4 * 60 * 60)
|
|
166
|
+
|
|
167
|
+
// Increment or decrement the value...
|
|
168
|
+
await Cache.increment('key')
|
|
169
|
+
await Cache.increment('key', amount)
|
|
170
|
+
await Cache.decrement('key')
|
|
171
|
+
await Cache.decrement('key', amount)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
#### Retrieve and store
|
|
175
|
+
|
|
176
|
+
`remember()` returns the value of a key, or, when it is missing, calls your function, stores what it returns for the
|
|
177
|
+
number of seconds you give, and returns that:
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
declare const seconds: number
|
|
181
|
+
// ---cut---
|
|
182
|
+
const users = await Cache.remember('users', seconds, () => DB.table('users').get())
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
`rememberForever()` does the same, and stores the value with no expiry:
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
const users = await Cache.rememberForever('users', () => DB.table('users').get())
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
The function may be async. A function that returns `undefined` stores nothing, so it is called again next time.
|
|
192
|
+
|
|
193
|
+
#### Retrieve and delete
|
|
194
|
+
|
|
195
|
+
`pull()` returns a value and removes it from the cache. Like `get()`, it returns `undefined`, or the default you give,
|
|
196
|
+
when the key is missing:
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
const value = await Cache.pull('key')
|
|
200
|
+
|
|
201
|
+
const valueOrDefault = await Cache.pull('key', 'default')
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Storing items in the cache
|
|
205
|
+
|
|
206
|
+
`put()` stores a value for a number of seconds:
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
await Cache.put('key', 'value', 10)
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Without a number of seconds, it stores the value with no expiry:
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
await Cache.put('key', 'value')
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Times are always in seconds, and may have a fraction: `Cache.put('key', 'value', 0.5)` keeps the value for half a
|
|
219
|
+
second, though the `database` store rounds it up to whole seconds.
|
|
220
|
+
|
|
221
|
+
> [!NOTE]
|
|
222
|
+
> Laravel's `put()` also takes a `DateTime` for when the value expires. Oriel's takes seconds only: for a date, pass
|
|
223
|
+
> the seconds until it, `Math.ceil((date.getTime() - Date.now()) / 1000)`.
|
|
224
|
+
|
|
225
|
+
#### What a value can be
|
|
226
|
+
|
|
227
|
+
A value is stored as JSON, in every store, the `array` one included, so it comes back the same whichever store you
|
|
228
|
+
use. Store plain data: strings, numbers, booleans, `null`, and arrays and plain objects of those. Anything else comes
|
|
229
|
+
back changed:
|
|
230
|
+
|
|
231
|
+
- a `Date` comes back as its ISO string, `'2026-01-02T03:04:05.000Z'`; read it with `new Date(value)`;
|
|
232
|
+
- a class instance, a model for one, comes back as a plain object with its fields, without its methods;
|
|
233
|
+
- a `Map` or a `Set` comes back as `{}`;
|
|
234
|
+
- `undefined` as a value is stored as `null`, and `NaN` and `Infinity` as `null`.
|
|
235
|
+
|
|
236
|
+
To cache a model, cache what you need from it, such as its attributes, and use them as plain data:
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
const post = await Cache.remember('post:1', 60, async () => (await Post.findOrFail(1)).toArray())
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
#### Store if not present
|
|
243
|
+
|
|
244
|
+
`add()` stores a value only when the key is missing, and tells you whether it did. It is atomic in every store, so
|
|
245
|
+
two requests that race for the same key can't both add it:
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
declare const seconds: number
|
|
249
|
+
// ---cut---
|
|
250
|
+
const added = await Cache.add('key', 'value', seconds)
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
#### Storing items forever
|
|
254
|
+
|
|
255
|
+
`forever()` stores a value with no expiry. Remove it with `forget()`:
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
await Cache.forever('key', 'value')
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
On the `database` store, "forever" is ten years.
|
|
262
|
+
|
|
263
|
+
### Removing items from the cache
|
|
264
|
+
|
|
265
|
+
`forget()` removes a key:
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
await Cache.forget('key')
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
So does `put()` with zero or a negative number of seconds:
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
await Cache.put('key', 'value', 0)
|
|
275
|
+
|
|
276
|
+
await Cache.put('key', 'value', -5)
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
`flush()` removes every key of the store:
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
await Cache.flush()
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
When the store has a prefix, as a new app's do, `flush()` removes only the keys that start with it, in the `database`
|
|
286
|
+
and `redis` stores. The `file` store deletes its folder, and the `array` store empties itself.
|
|
287
|
+
|
|
288
|
+
> [!WARNING]
|
|
289
|
+
> A `redis` store with no prefix flushes every key of its Redis database. Give the cache a Redis database of its own,
|
|
290
|
+
> as a new app's `cache` connection does.
|
|
291
|
+
|
|
292
|
+
### The cache helper
|
|
293
|
+
|
|
294
|
+
`cache()` reads and writes the default store too. With a key, it returns the key's value. With an object, it stores
|
|
295
|
+
each of its entries, for the number of seconds you give, or with no expiry:
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
declare const seconds: number
|
|
299
|
+
// ---cut---
|
|
300
|
+
const value = await cache('key')
|
|
301
|
+
|
|
302
|
+
await cache({ key: 'value' }, seconds)
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
With nothing, it returns the cache, whose methods are the facade's:
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
declare const seconds: number
|
|
309
|
+
// ---cut---
|
|
310
|
+
const users = await cache().remember('users', seconds, () => DB.table('users').get())
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
> [!NOTE]
|
|
314
|
+
> Laravel's cache also has `flexible()` (stale while revalidate), `memo()`, `putMany()`, `touch()` and a `failover`
|
|
315
|
+
> driver. They aren't in Oriel: write each key with `put()`, and wrap a store's calls yourself to fall back on
|
|
316
|
+
> another.
|
|
317
|
+
|
|
318
|
+
## Cache tags
|
|
319
|
+
|
|
320
|
+
Tags group related keys so you can remove them together. Only the `redis` store has them: on any other store,
|
|
321
|
+
`tags()` throws "The [DatabaseStore] cache store does not support tags. Only the redis store does.".
|
|
322
|
+
|
|
323
|
+
### Storing tagged cache items
|
|
324
|
+
|
|
325
|
+
`tags()` takes one tag or a list, and returns a cache whose keys belong to those tags:
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
declare const john: object
|
|
329
|
+
declare const anne: object
|
|
330
|
+
declare const seconds: number
|
|
331
|
+
// ---cut---
|
|
332
|
+
await Cache.tags(['people', 'artists']).put('John', john, seconds)
|
|
333
|
+
await Cache.tags(['people', 'authors']).put('Anne', anne, seconds)
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
### Accessing tagged cache items
|
|
337
|
+
|
|
338
|
+
A tagged key is read with the same tags it was stored with, in any order:
|
|
339
|
+
|
|
340
|
+
```ts
|
|
341
|
+
const john = await Cache.tags(['people', 'artists']).get('John')
|
|
342
|
+
|
|
343
|
+
const anne = await Cache.tags(['people', 'authors']).get('Anne')
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
### Removing tagged cache items
|
|
347
|
+
|
|
348
|
+
`flush()` on a tagged cache removes every key stored with any of its tags. This removes both `John` and `Anne`:
|
|
349
|
+
|
|
350
|
+
```ts
|
|
351
|
+
await Cache.tags(['people', 'authors']).flush()
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
This removes only `Anne`, the one tagged `authors`:
|
|
355
|
+
|
|
356
|
+
```ts
|
|
357
|
+
await Cache.tags('authors').flush()
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
> [!WARNING]
|
|
361
|
+
> Flushing a tag doesn't delete its keys from Redis: it gives the tag a new version, so they can't be read any more,
|
|
362
|
+
> and they stay until they expire. Give tagged keys a number of seconds, or they stay for good.
|
|
363
|
+
|
|
364
|
+
## Atomic locks
|
|
365
|
+
|
|
366
|
+
A lock lets one process at a time do a piece of work, across every process and server that shares the store. Every
|
|
367
|
+
store has locks. The `database` store keeps them in the `cache_locks` table, and the `redis` store in Redis; the
|
|
368
|
+
`file` store's work for the processes of one machine, and the `array` store's only within one process.
|
|
369
|
+
|
|
370
|
+
### Managing locks
|
|
371
|
+
|
|
372
|
+
`Cache.lock()` takes the lock's name and how many seconds it lasts at most. `get()` tries once to take it, and tells
|
|
373
|
+
you whether it did:
|
|
374
|
+
|
|
375
|
+
```ts
|
|
376
|
+
const lock = Cache.lock('foo', 10)
|
|
377
|
+
|
|
378
|
+
if (await lock.get()) {
|
|
379
|
+
// Lock acquired for 10 seconds...
|
|
380
|
+
|
|
381
|
+
await lock.release()
|
|
382
|
+
}
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
`get()` also takes a function. When the lock is taken, it runs the function, releases the lock, and returns what the
|
|
386
|
+
function returned; when it isn't, it returns `false`:
|
|
387
|
+
|
|
388
|
+
```ts
|
|
389
|
+
await Cache.lock('foo', 10).get(async () => {
|
|
390
|
+
// Lock acquired for 10 seconds and automatically released...
|
|
391
|
+
})
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
`block()` waits for the lock, up to a number of seconds. When the time is up, it throws a `LockTimeoutError`,
|
|
395
|
+
"Unable to acquire lock [foo].":
|
|
396
|
+
|
|
397
|
+
```ts
|
|
398
|
+
const lock = Cache.lock('foo', 10)
|
|
399
|
+
|
|
400
|
+
try {
|
|
401
|
+
await lock.block(5)
|
|
402
|
+
|
|
403
|
+
// Lock acquired after waiting a maximum of 5 seconds...
|
|
404
|
+
} catch (error) {
|
|
405
|
+
if (!(error instanceof LockTimeoutError)) throw error
|
|
406
|
+
|
|
407
|
+
// Unable to acquire lock...
|
|
408
|
+
} finally {
|
|
409
|
+
await lock.release()
|
|
410
|
+
}
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
Given a function, `block()` runs it once the lock is taken, and releases the lock after:
|
|
414
|
+
|
|
415
|
+
```ts
|
|
416
|
+
await Cache.lock('foo', 10).block(5, async () => {
|
|
417
|
+
// Lock acquired for 10 seconds after waiting a maximum of 5 seconds...
|
|
418
|
+
})
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
A lock without a number of seconds lasts until it is released. `release()` releases a lock only for its owner, the
|
|
422
|
+
lock object that took it, and returns whether it did.
|
|
423
|
+
|
|
424
|
+
> [!NOTE]
|
|
425
|
+
> On every store but `database`, a lock is a key of the cache: `Cache.lock('foo')` and `Cache.put('foo', …)` name the
|
|
426
|
+
> same entry, as in Laravel. Give your locks names that your keys don't use.
|
|
427
|
+
|
|
428
|
+
### Managing locks across processes
|
|
429
|
+
|
|
430
|
+
A lock can be taken in one process and released in another, such as a job the request dispatches. Each lock has an
|
|
431
|
+
owner, a random string that `owner()` returns. Pass it to the job:
|
|
432
|
+
|
|
433
|
+
```ts
|
|
434
|
+
declare const id: number
|
|
435
|
+
// @filename: app/Jobs/ProcessPodcast.ts
|
|
436
|
+
import { Queueable } from '@orieljs/framework'
|
|
437
|
+
import type { Podcast } from '../Models/Podcast'
|
|
438
|
+
export class ProcessPodcast extends Queueable {
|
|
439
|
+
constructor(public podcast: Podcast, public owner: string) {
|
|
440
|
+
super()
|
|
441
|
+
}
|
|
442
|
+
async handle(): Promise<void> {}
|
|
443
|
+
}
|
|
444
|
+
// ---cut---
|
|
445
|
+
const podcast = await Podcast.findOrFail(id)
|
|
446
|
+
|
|
447
|
+
const lock = Cache.lock('processing', 120)
|
|
448
|
+
|
|
449
|
+
if (await lock.get()) {
|
|
450
|
+
await ProcessPodcast.dispatch(podcast, lock.owner())
|
|
451
|
+
}
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
In the job, `Cache.lock()` takes the owner as its third argument, and the lock it returns can release the one the
|
|
455
|
+
request took:
|
|
456
|
+
|
|
457
|
+
```ts title="app/Jobs/ProcessPodcast.ts"
|
|
458
|
+
import { Cache, Queueable } from '@orieljs/framework'
|
|
459
|
+
import type { Podcast } from '../Models/Podcast'
|
|
460
|
+
|
|
461
|
+
export class ProcessPodcast extends Queueable {
|
|
462
|
+
constructor(
|
|
463
|
+
public podcast: Podcast,
|
|
464
|
+
public owner: string,
|
|
465
|
+
) {
|
|
466
|
+
super()
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async handle(): Promise<void> {
|
|
470
|
+
// ...
|
|
471
|
+
|
|
472
|
+
await Cache.lock('processing', 120, this.owner).release()
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
`forceRelease()` releases a lock whoever owns it:
|
|
478
|
+
|
|
479
|
+
```ts
|
|
480
|
+
await Cache.lock('processing').forceRelease()
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
> [!NOTE]
|
|
484
|
+
> Laravel restores a lock with `Cache::restoreLock()`; Oriel's `Cache.lock()` takes the owner instead. Laravel's
|
|
485
|
+
> `withoutOverlapping()` and `funnel()` aren't in Oriel: take a lock with `block()`.
|
|
486
|
+
|
|
487
|
+
## Rate limiting
|
|
488
|
+
|
|
489
|
+
The `RateLimiter` counts how often something happens within a window of time, in the cache. The `throttle` route
|
|
490
|
+
middleware uses it, and you can use it for anything else your app needs to limit. It keeps its counts in the store
|
|
491
|
+
that `limiter` in `config/cache.ts` names, or in the default store.
|
|
492
|
+
|
|
493
|
+
`hit()` adds one to a key's count, and starts the key's window, 60 seconds or the number you give, when the count
|
|
494
|
+
starts. `tooManyAttempts()` tells you whether the count has reached a maximum within the window:
|
|
495
|
+
|
|
496
|
+
```ts title="routes/web.ts"
|
|
497
|
+
import { RateLimiter, request, Route } from '@orieljs/framework'
|
|
498
|
+
import type { User } from '../app/Models/User'
|
|
499
|
+
|
|
500
|
+
Route.post('/messages', async () => {
|
|
501
|
+
const user = await request().user<User>()
|
|
502
|
+
const key = `send-message:${user?.id}`
|
|
503
|
+
|
|
504
|
+
if (await RateLimiter.tooManyAttempts(key, 5)) {
|
|
505
|
+
const seconds = await RateLimiter.availableIn(key)
|
|
506
|
+
|
|
507
|
+
return `You may try again in ${seconds} seconds.`
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
await RateLimiter.hit(key, 60)
|
|
511
|
+
|
|
512
|
+
// Send the message...
|
|
513
|
+
|
|
514
|
+
return 'Sent.'
|
|
515
|
+
})
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
Once the window ends, the count starts again from 0. The other methods:
|
|
519
|
+
|
|
520
|
+
- `attempts(key)`: the count so far;
|
|
521
|
+
- `remaining(key, max)`: how many are left before the maximum;
|
|
522
|
+
- `availableIn(key)`: the seconds until the window ends;
|
|
523
|
+
- `clear(key)`: forgets the count and the window.
|
|
524
|
+
|
|
525
|
+
A key's accents don't count: `zoë` and `zoe` share a count.
|
|
526
|
+
|
|
527
|
+
> [!NOTE]
|
|
528
|
+
> Laravel's `RateLimiter::attempt()`, which runs a function while the count allows it, and `RateLimiter::for()`,
|
|
529
|
+
> which names a limit for routes, aren't in Oriel. Check with `tooManyAttempts()` and count with `hit()`, as above.
|
|
530
|
+
|
|
531
|
+
## Adding custom cache drivers
|
|
532
|
+
|
|
533
|
+
### Writing the driver
|
|
534
|
+
|
|
535
|
+
A driver is a store, which keeps the values, wrapped in a repository, which gives it the cache's API. Write the store
|
|
536
|
+
as a class with the methods of the `CacheStore` type. This one keeps its values in a `Map`, as JSON; a store over
|
|
537
|
+
another service has the same methods:
|
|
538
|
+
|
|
539
|
+
```ts title="app/Extensions/MapStore.ts"
|
|
540
|
+
import type { CacheStore } from '@orieljs/framework'
|
|
541
|
+
|
|
542
|
+
interface Entry {
|
|
543
|
+
json: string
|
|
544
|
+
expiresAt: number
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export class MapStore implements CacheStore {
|
|
548
|
+
private readonly entries = new Map<string, Entry>()
|
|
549
|
+
|
|
550
|
+
async get(key: string): Promise<unknown> {
|
|
551
|
+
const entry = this.live(key)
|
|
552
|
+
return entry === undefined ? undefined : JSON.parse(entry.json)
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async many(keys: string[]): Promise<Record<string, unknown>> {
|
|
556
|
+
const found: Record<string, unknown> = {}
|
|
557
|
+
for (const key of keys) {
|
|
558
|
+
const entry = this.live(key)
|
|
559
|
+
if (entry !== undefined) found[key] = JSON.parse(entry.json)
|
|
560
|
+
}
|
|
561
|
+
return found
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async put(key: string, value: unknown, seconds: number): Promise<boolean> {
|
|
565
|
+
this.entries.set(key, { json: JSON.stringify(value) ?? 'null', expiresAt: Date.now() + seconds * 1000 })
|
|
566
|
+
return true
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async putForever(key: string, value: unknown): Promise<boolean> {
|
|
570
|
+
this.entries.set(key, { json: JSON.stringify(value) ?? 'null', expiresAt: Infinity })
|
|
571
|
+
return true
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async add(key: string, value: unknown, seconds?: number): Promise<boolean> {
|
|
575
|
+
if (this.live(key) !== undefined) return false
|
|
576
|
+
const expiresAt = seconds === undefined ? Infinity : Date.now() + seconds * 1000
|
|
577
|
+
this.entries.set(key, { json: JSON.stringify(value) ?? 'null', expiresAt })
|
|
578
|
+
return true
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async increment(key: string, by: number): Promise<number> {
|
|
582
|
+
const entry = this.live(key)
|
|
583
|
+
const next = (entry === undefined ? 0 : Number(JSON.parse(entry.json))) + by
|
|
584
|
+
this.entries.set(key, { json: String(next), expiresAt: entry?.expiresAt ?? Infinity })
|
|
585
|
+
return next
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async decrement(key: string, by: number): Promise<number> {
|
|
589
|
+
return this.increment(key, -by)
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
async forget(key: string): Promise<boolean> {
|
|
593
|
+
return this.entries.delete(key)
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
async flush(): Promise<boolean> {
|
|
597
|
+
this.entries.clear()
|
|
598
|
+
return true
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/** The key's entry, unless it is missing or has expired. */
|
|
602
|
+
private live(key: string): Entry | undefined {
|
|
603
|
+
const entry = this.entries.get(key)
|
|
604
|
+
return entry !== undefined && entry.expiresAt > Date.now() ? entry : undefined
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
`get()` returns `undefined` for a missing or expired key, and `many()` leaves those keys out. `add()` must be atomic,
|
|
610
|
+
since locks are built on it: this one checks and writes with no `await` in between. `increment()` starts a missing
|
|
611
|
+
key at 0 and keeps an existing key's expiry. The repository deals with a missing number of seconds, and with zero or
|
|
612
|
+
less, before your `put()` is called.
|
|
613
|
+
|
|
614
|
+
### Registering the driver
|
|
615
|
+
|
|
616
|
+
`Cache.extend()` registers the driver, with a function that receives the store's config and returns the repository.
|
|
617
|
+
Do it in a service provider's `boot()`:
|
|
618
|
+
|
|
619
|
+
```ts title="app/Providers/AppServiceProvider.ts"
|
|
620
|
+
// @filename: app/Extensions/MapStore.ts
|
|
621
|
+
import type { CacheStore } from '@orieljs/framework'
|
|
622
|
+
export declare class MapStore implements CacheStore {
|
|
623
|
+
get(key: string): Promise<unknown>
|
|
624
|
+
many(keys: string[]): Promise<Record<string, unknown>>
|
|
625
|
+
put(key: string, value: unknown, seconds: number): Promise<boolean>
|
|
626
|
+
putForever(key: string, value: unknown): Promise<boolean>
|
|
627
|
+
add(key: string, value: unknown, seconds?: number): Promise<boolean>
|
|
628
|
+
increment(key: string, by: number): Promise<number>
|
|
629
|
+
decrement(key: string, by: number): Promise<number>
|
|
630
|
+
forget(key: string): Promise<boolean>
|
|
631
|
+
flush(): Promise<boolean>
|
|
632
|
+
}
|
|
633
|
+
// ---cut---
|
|
634
|
+
import { Cache, CacheRepositoryImpl, ServiceProvider } from '@orieljs/framework'
|
|
635
|
+
import { MapStore } from '../Extensions/MapStore'
|
|
636
|
+
|
|
637
|
+
export class AppServiceProvider extends ServiceProvider {
|
|
638
|
+
override boot(): void {
|
|
639
|
+
Cache.extend('map', () => new CacheRepositoryImpl(new MapStore()))
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
Then add a store with that driver to `config/cache.ts`, `map: { driver: 'map' }`, and name it in `CACHE_STORE` or in
|
|
645
|
+
`Cache.store('map')`. The function receives the store's entry of `config/cache.ts`, with the cache's `prefix` when
|
|
646
|
+
the entry has none of its own: a store that keeps other apps' keys too should start its keys with it. Locks on the
|
|
647
|
+
new store are built on its `add()`.
|