@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.
Files changed (86) hide show
  1. package/NOTICE +4 -0
  2. package/README.md +10 -1
  3. package/TRADEMARKS.md +33 -0
  4. package/dist/{chunk-POEP4B2M.js → chunk-7ZQZYDOC.js} +745 -105
  5. package/dist/chunk-7ZQZYDOC.js.map +1 -0
  6. package/dist/{chunk-I63ZFMX6.js → chunk-GVNLZZTN.js} +1 -1
  7. package/dist/chunk-GVNLZZTN.js.map +1 -0
  8. package/dist/{chunk-ZOEPKBVF.js → chunk-SPQUX3C7.js} +1347 -428
  9. package/dist/chunk-SPQUX3C7.js.map +1 -0
  10. package/dist/{chunk-MFTZQMZM.js → chunk-VOYW3R5J.js} +4 -4
  11. package/dist/chunk-VOYW3R5J.js.map +1 -0
  12. package/dist/contracts.d.ts +515 -17
  13. package/dist/contracts.js +1 -1
  14. package/dist/index.d-WQpfdIbG.d.ts +630 -0
  15. package/dist/index.d.ts +964 -152
  16. package/dist/index.js +4279 -866
  17. package/dist/index.js.map +1 -1
  18. package/dist/{jsx-runtime-CBZQYYDS.d.ts → jsx-runtime-6um4Mwfb.d.ts} +96 -5
  19. package/dist/jsx-runtime.d.ts +2 -2
  20. package/dist/jsx-runtime.js +3 -3
  21. package/dist/{testing-BM481Hdz.d.ts → testing-CKwfWJGn.d.ts} +180 -15
  22. package/dist/testing.d.ts +2 -2
  23. package/dist/testing.js +3 -3
  24. package/docs/authentication.md +487 -0
  25. package/docs/authorization.md +435 -0
  26. package/docs/cache.md +647 -0
  27. package/docs/collections.md +2270 -0
  28. package/docs/configuration.md +334 -0
  29. package/docs/console.md +651 -0
  30. package/docs/container.md +430 -0
  31. package/docs/controllers.md +406 -0
  32. package/docs/csrf.md +126 -0
  33. package/docs/database-testing.md +144 -0
  34. package/docs/database.md +236 -0
  35. package/docs/deployment.md +276 -0
  36. package/docs/eloquent-collections.md +218 -0
  37. package/docs/eloquent-factories.md +255 -0
  38. package/docs/eloquent-mutators.md +254 -0
  39. package/docs/eloquent-observers.md +145 -0
  40. package/docs/eloquent-relationships.md +557 -0
  41. package/docs/eloquent.md +494 -0
  42. package/docs/encryption.md +104 -0
  43. package/docs/errors.md +318 -0
  44. package/docs/events.md +336 -0
  45. package/docs/facades.md +278 -0
  46. package/docs/hashing.md +118 -0
  47. package/docs/helpers.md +1281 -0
  48. package/docs/http-tests.md +386 -0
  49. package/docs/inertia.md +485 -0
  50. package/docs/installation.md +209 -0
  51. package/docs/lifecycle.md +234 -0
  52. package/docs/live-tests.md +226 -0
  53. package/docs/live.md +910 -0
  54. package/docs/logging.md +255 -0
  55. package/docs/mail.md +619 -0
  56. package/docs/middleware.md +384 -0
  57. package/docs/migrations.md +416 -0
  58. package/docs/mocking.md +264 -0
  59. package/docs/navigation.json +273 -0
  60. package/docs/notifications.md +225 -0
  61. package/docs/pagination.md +247 -0
  62. package/docs/passwords.md +207 -0
  63. package/docs/providers.md +187 -0
  64. package/docs/queries.md +600 -0
  65. package/docs/queues.md +330 -0
  66. package/docs/requests.md +533 -0
  67. package/docs/responses.md +444 -0
  68. package/docs/routing.md +738 -0
  69. package/docs/scheduling.md +277 -0
  70. package/docs/seeding.md +122 -0
  71. package/docs/session.md +409 -0
  72. package/docs/starter-kits.md +396 -0
  73. package/docs/storage.md +192 -0
  74. package/docs/structure.md +264 -0
  75. package/docs/testing.md +165 -0
  76. package/docs/time-travel.md +138 -0
  77. package/docs/validation.md +1792 -0
  78. package/docs/verification.md +152 -0
  79. package/docs/views.md +566 -0
  80. package/docs/vite.md +322 -0
  81. package/package.json +34 -32
  82. package/dist/chunk-I63ZFMX6.js.map +0 -1
  83. package/dist/chunk-MFTZQMZM.js.map +0 -1
  84. package/dist/chunk-POEP4B2M.js.map +0 -1
  85. package/dist/chunk-ZOEPKBVF.js.map +0 -1
  86. 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()`.