@dotpress/cache 0.0.0-stage → 2.0.0-alpha.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-present Renaud Guyon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,306 @@
1
- # Temporary Holding Version
1
+ # @dotpress/cache
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Caching for [Dotpress](https://dotpress.dev), at two levels:
4
+
5
+ - **memo**: values loaded once per unit of work (an HTTP request, an event message, a job), shared by every function that needs them, with single flight and batching;
6
+ - **cache**: values kept across requests and instances, in interchangeable stores, failing open when the store is down.
7
+
8
+ Works without a Dotpress app: in a script, a worker or a Lambda function.
9
+
10
+ > 🚧 Dotpress V2 is in alpha (`2.0.0-alpha`): the API may still change before 2.0.0.
11
+
12
+ ```sh
13
+ pnpm add @dotpress/cache
14
+ pnpm add @dotpress/core # for cachePlugin
15
+ ```
16
+
17
+ ## Memo
18
+
19
+ A middleware, a route handler and a service all need the current user: with a memo, it is loaded once per request.
20
+
21
+ ```ts
22
+ import { createMemo, defineMemo } from '@dotpress/cache'
23
+
24
+ export const loadUser = defineMemo({
25
+ name: 'user',
26
+ load: (id: string) => db.users.findById(id),
27
+ })
28
+
29
+ const memo = createMemo() // one per request, message or job
30
+ const user = await memo(loadUser, id) // loads
31
+ const same = await memo(loadUser, id) // memoized
32
+ ```
33
+
34
+ - **Single flight**: calls made while a value loads share the same load. Three parallel calls with the same id make one query.
35
+ - **Failures are shared, not kept**: the calls in progress fail together, and the next call loads again.
36
+ - **Keys**: the arguments are compared by value (strings, numbers, booleans, `null`, `undefined`, bigints, dates, arrays, plain objects; the order of object properties does not matter). For other arguments, add a `key` option that returns a string: `key: (user) => user.id`.
37
+ - **Updates**: `memo.set(loadUser, id, user)` after an update, `memo.delete(loadUser, id)` to load again, `memo.clear(loadUser)` or `memo.clear()`.
38
+
39
+ A memo lives as long as its unit of work: create one per request, per event message, per job, never one per process (it would grow forever and serve old values). Values shared across requests belong in the cache.
40
+
41
+ Values are the objects returned by `load`, not copies: a caller that mutates one changes it for the others.
42
+
43
+ ### Batching
44
+
45
+ With `batch` instead of `load`, the keys requested in the same tick are loaded in one call: no N+1 queries when a list renders the author of each item.
46
+
47
+ ```ts
48
+ export const loadAuthor = defineMemo({
49
+ name: 'author',
50
+ // A Map by id: missing ids give undefined.
51
+ batch: async (ids: readonly string[]) => {
52
+ const users = await db.users.findManyById(ids)
53
+ return new Map(users.map((user) => [user.id, user]))
54
+ },
55
+ maxBatchSize: 100, // optional: keys per call
56
+ })
57
+
58
+ const authors = await Promise.all(posts.map((post) => memo(loadAuthor, post.authorId))) // one query
59
+ ```
60
+
61
+ `batch` receives the keys without duplicates, and returns either a `Map` by key, or an array of values in the order of the keys, where an `Error` fails its key only. An array of the wrong length fails every key with a `MemoBatchError`.
62
+
63
+ Keys requested before the batch runs join it: it runs on a timer (`setTimeout(run, 0)`), after the code that requested them, awaits included. Replace it with `schedule: (run) => …`. Keys requested once it has started go to the next batch.
64
+
65
+ ## Cache
66
+
67
+ ```ts
68
+ import { createCache, createMemoryStore, defineCacheEntry } from '@dotpress/cache'
69
+ import { z } from 'zod'
70
+
71
+ export const userCache = defineCacheEntry({
72
+ name: 'user',
73
+ key: (id: string) => id,
74
+ schema: z.object({ id: z.string(), name: z.string(), createdAt: z.coerce.date() }), // optional
75
+ ttl: '1h',
76
+ })
77
+
78
+ const cache = createCache({ store: createMemoryStore(), prefix: 'app', ttl: '10m', logger })
79
+
80
+ const user = await cache.getOrSet(userCache, id, () => db.users.findById(id))
81
+ await cache.get(userCache, id) // undefined when missing or expired
82
+ await cache.set(userCache, id, user)
83
+ await cache.delete(userCache, id) // after an update
84
+ ```
85
+
86
+ - **`getOrSet`** reads the value, or loads it with the last argument and caches it. Calls made while it loads share the load (single flight, per cache). A failed load is not cached: the error reaches the caller, unless the grace period serves a stale value (see [Resilience](#resilience)).
87
+ - **`get`** returns fresh values only: undefined when missing, expired or invalidated by a tag.
88
+ - **Keys**: `prefix:name:vVersion:key`, like `app:user:v1:42`. `key` returns a string, a number, or a value compared like the arguments of a memo; without `key`, the argument (or the arguments) is the key. Keys longer than the store accepts are hashed (SHA-256).
89
+ - **`version`** (1 by default) is part of the key: increase it when the shape of the values changes, and the values written by the previous version are ignored.
90
+ - **Durations**: a number of seconds, or a string: `'500ms'`, `'30s'`, `'10m'`, `'1h'`, `'7d'`. The `ttl` of an entry overrides the `ttl` of the cache (1 hour by default).
91
+ - **Types**: with a `schema`, the value type comes from it; without, `getOrSet` returns the type of the loader, and `get` returns `unknown`. `defineCacheEntry<[id: string], User>({ … })` declares it.
92
+ - **`withLogger(logger)`** returns the same cache with another logger (the logger of a request).
93
+
94
+ ### Values
95
+
96
+ Values are serialized with JSON (or `serializer: { serialize, deserialize }`, like superjson). `undefined` is not cached; `null` is.
97
+
98
+ With a `schema`, every value read is validated: a value written by an older version of the app that no longer matches is a miss, not a crash. Values are also checked when written, as they will read back: JSON turns dates into strings, so `z.date()` fails and the value is not cached (with a warning), while `z.coerce.date()` works. `getOrSet` then returns the value as it reads back, so a cache hit and a miss return the same thing.
99
+
100
+ The memory store serializes too: it behaves like a shared store, and a caller cannot change a cached object.
101
+
102
+ ### Resilience
103
+
104
+ Some data is better a little outdated than missing: a price list, a catalog, a configuration. An entry (or the cache, for every entry) sets how stale values are served:
105
+
106
+ ```ts
107
+ export const catalogCache = defineCacheEntry({
108
+ name: 'catalog',
109
+ ttl: '5m',
110
+ grace: '1d', // stale-if-error: serve the expired value when the loader fails
111
+ graceBackoff: '30s', // then do not call the failing loader again for 30 seconds
112
+ staleWhileRevalidate: '1m', // serve the expired value and refresh it in the background
113
+ timeout: '300ms', // a slower loader serves the stale value, and goes on in the background
114
+ hardTimeout: '5s', // a slower loader fails (or serves the stale value)
115
+ })
116
+ ```
117
+
118
+ | Option | Default | Effect, once the `ttl` is over |
119
+ | --- | --- | --- |
120
+ | `grace` | 0 (disabled) | During this time, when the loader fails or times out, `getOrSet` returns the expired value instead of the error, with a `warn` log. |
121
+ | `graceBackoff` | `'10s'` | After such a failure, the loader is not called again during this delay: the expired value is served directly. A successful load ends it. |
122
+ | `staleWhileRevalidate` | 0 (disabled) | During this time, `getOrSet` returns the expired value at once and reloads it in the background (once per key). Failed refreshes are logged. |
123
+ | `timeout` | none | With an expired value in its grace period, a load slower than this returns that value (`warn` log); the load goes on and caches its result. |
124
+ | `hardTimeout` | none | A load slower than this fails with a `CacheTimeoutError`, or returns the expired value of the grace period. The load goes on and caches its result. |
125
+ | `lock` | none | Distributed single flight (stores with locks, like Redis): before loading a missing value, the instance takes a lock for at most this duration; the other instances wait for the value meanwhile (reading it every 50 ms), then load it themselves if the lock was not released. Set it above the duration of the loader. |
126
+
127
+ Values stay in the store for `ttl` plus the longer of `grace` and `staleWhileRevalidate`. Without a grace period, a failing loader fails the call, as without cache.
128
+
129
+ ### Tags
130
+
131
+ Tags expire related values at once, on every store:
132
+
133
+ ```ts
134
+ export const productCache = defineCacheEntry({
135
+ name: 'product',
136
+ key: (id: string) => id,
137
+ tags: (id: string) => [`product:${id}`, 'catalog'],
138
+ })
139
+
140
+ await cache.invalidateTags(['catalog']) // after an import
141
+ ```
142
+
143
+ Each tag has a random token in the store (`prefix:_tag:name`, kept 30 days or longer); a value records the tokens of its tags when written, and `invalidateTags` replaces them. Reading a value with tags reads their tokens too (one more round trip, grouped with `getMany` when the store has it). A value whose token changed or vanished is expired: `get` returns undefined and `getOrSet` reloads it, but with a `grace` period it can still be served when the loader fails, until `ttl` plus `grace` (never by `staleWhileRevalidate`: the data changed). When the tokens cannot be read, values with tags are treated as invalidated, and new values are not cached (creating tokens again would invalidate every value of their tags). `delete` removes a value for good.
144
+
145
+ ### Events
146
+
147
+ `onEvent(event)` is called for each result of `get` and `getOrSet`, for metrics: `{ type: 'hit' | 'miss' | 'stale', reason?: 'revalidate' | 'error' | 'timeout' | 'backoff', entry, key }`. The same events are logged at `debug` level.
148
+
149
+ ### Store failures
150
+
151
+ The cache is an optimization: by default it **fails open**. When the store fails, a read is a miss (the loader runs), a write is skipped, and the app keeps working. Every failure is reported:
152
+
153
+ - a log: `warn` for reads and writes, `error` for deletions and invalidations (they may leave outdated values), with the store, the operation, the key and the entry;
154
+ - `onError(error, { operation, key, entry })`, for metrics or alerts (its own errors are logged and ignored); `operation` is `get`, `set`, `delete` or `invalidate`.
155
+
156
+ ```ts
157
+ const cache = createCache({
158
+ store,
159
+ logger,
160
+ onError: (error, { operation }) => metrics.increment('cache.store_error', { operation }),
161
+ storeTimeout: '200ms', // a slower store counts as a failure
162
+ failOpen: true, // false: throw a CacheStoreError instead
163
+ })
164
+ ```
165
+
166
+ ## Stores
167
+
168
+ | Store | Import | Use |
169
+ | --- | --- | --- |
170
+ | `createMemoryStore({ maxEntries })` | `@dotpress/cache` | memory of the process: one instance, development, tests. Least recently used entries are removed beyond `maxEntries` (10,000 by default) |
171
+ | `createRedisStore({ client })` | `@dotpress/cache/redis` | Redis or Valkey, with a node-redis or ioredis client |
172
+ | `createUpstashStore({ url, token })` | `@dotpress/cache/upstash` | Upstash Redis over its REST API: every runtime, edge included |
173
+ | `createCloudflareKvStore({ kv })` | `@dotpress/cache/cloudflare-kv` | Workers KV, through the binding of a Worker: values read often and changed rarely |
174
+
175
+ A store implements `CacheStore`: `name`, `get(key)`, `set(key, value, ttl)` (strings, `ttl` in milliseconds), `delete(key)`, and optionally `getMany(keys)` (one round trip for the tokens of tags), `lock(key, ttl)` / `unlock(key, token)` (the `lock` option) and `maxKeyLength` (bytes).
176
+
177
+ ### Redis and Valkey
178
+
179
+ ```ts
180
+ import { createClient } from 'redis' // or: import { Redis } from 'ioredis'
181
+ import { createRedisStore } from '@dotpress/cache/redis'
182
+
183
+ const client = createClient({ url: process.env.REDIS_URL })
184
+ client.on('error', (error) => logger.error({ error }, 'Redis error'))
185
+ await client.connect()
186
+
187
+ const cache = createCache({ store: createRedisStore({ client }), prefix: 'app', storeTimeout: '200ms' })
188
+ // On shutdown: await client.close()
189
+ ```
190
+
191
+ The store uses the client of the app, which connects and closes it: [node-redis](https://github.com/redis/node-redis) (`redis`, v4 or later; the client recommended by Redis), or [ioredis](https://github.com/redis/ioredis) (`new Redis(url)`; also used by BullMQ). It works with Redis and [Valkey](https://valkey.io), and any server of the same protocol (Dragonfly, KeyDB…). Commands: `GET`, `MGET` (tags), `SET … PX`, `DEL`, and for the `lock` option `SET … NX PX` and a Lua script that releases a lock only with its token (no `DELEX`, which needs Redis 8.4).
192
+
193
+ - Cluster clients (`createCluster()`, `new Cluster()`) are refused for now: use a standalone server, or the primary of a replicated one.
194
+ - Set `storeTimeout`: while the client reconnects, its commands wait, and the cache would wait with them.
195
+ - Valkey on a managed service (ElastiCache, Memorystore, Upstash with its Redis protocol endpoint) works the same way.
196
+
197
+ ### Upstash
198
+
199
+ ```ts
200
+ import { createUpstashStore } from '@dotpress/cache/upstash'
201
+
202
+ const store = createUpstashStore({
203
+ url: env.UPSTASH_REDIS_REST_URL,
204
+ token: env.UPSTASH_REDIS_REST_TOKEN, // the standard token: the read-only one cannot write
205
+ })
206
+ const cache = createCache({ store, prefix: 'app', storeTimeout: '500ms' })
207
+ ```
208
+
209
+ The store calls the [REST API](https://upstash.com/docs/redis/features/restapi) of [Upstash Redis](https://upstash.com/docs/redis) with `fetch`: no dependency, no connection to open or close, and it runs on Cloudflare Workers, Vercel, Deno, Bun and Node.js. Each operation is one HTTPS request (`GET`, `MGET` for tags, `SET … PX`, `DEL`, and `SET … NX PX` plus a Lua script for the `lock` option). Errors of the API (`401`, `400 ERR …`) are store failures: logged, and the cache fails open. A request is limited to 10 MB on the free and pay-as-you-go plans. The `fetch` option replaces the global `fetch`.
210
+
211
+ The `@upstash/redis` SDK is not needed: by default it applies `JSON.parse` to every reply, which would change the values of the cache.
212
+
213
+ ### Cloudflare Workers KV
214
+
215
+ ```jsonc
216
+ // wrangler.jsonc
217
+ { "kv_namespaces": [{ "binding": "CACHE", "id": "<namespace id>" }] }
218
+ ```
219
+
220
+ ```ts
221
+ import { env } from 'cloudflare:workers'
222
+ import { createCloudflareKvStore } from '@dotpress/cache/cloudflare-kv'
223
+
224
+ const cache = createCache({ store: createCloudflareKvStore({ kv: () => env.CACHE }), prefix: 'app' })
225
+ ```
226
+
227
+ `kv` is the binding, or a function that returns it (resolved on use). [Workers KV](https://developers.cloudflare.com/kv) suits values read often and changed rarely (configuration, catalogs, rendered pages); keep in mind its limits:
228
+
229
+ | Limit | Effect |
230
+ | --- | --- |
231
+ | Eventual consistency | A write, deletion or invalidation can take 60 seconds or more to reach other locations: other locations serve the previous value meanwhile. |
232
+ | Expiration of 60 seconds at least | Shorter `ttl` are kept in the value: the cache expires them on time, KV removes them after 60 seconds. |
233
+ | One write per second per key | Faster writes fail with a 429: a store failure (logged, fails open). Tags write a token per tag on `invalidateTags()`. |
234
+ | Keys of 512 bytes | Longer keys are hashed. |
235
+ | No locks | The `lock` option is ignored: single flight stays per instance. |
236
+ | Free plan: 1,000 writes per day | Cache misses write: count them before choosing KV. |
237
+
238
+ `cacheTtl` (30 seconds at least) keeps read values in the cache of the location for longer: faster reads, but changes are seen even later. Tokens of tags are read in bulk (100 keys per read).
239
+
240
+ ## Dotpress plugin
241
+
242
+ ```ts
243
+ import { cachePlugin } from '@dotpress/cache/plugin'
244
+
245
+ await createApp({ adapter, plugins: [cachePlugin({ cache })] })
246
+
247
+ const authenticate = defineMiddleware(async (ctx) => {
248
+ ctx.session = await ctx.memo(loadSession, ctx.req.headers.authorization)
249
+ })
250
+
251
+ defineRoute({
252
+ path: '/me',
253
+ middlewares: [authenticate],
254
+ handler: async ({ memo, cache, session }) => {
255
+ const user = await memo(loadUser, session.userId) // loaded once per request
256
+ const plan = await cache.getOrSet(planCache, user.planId, () => billing.getPlan(user.planId))
257
+ return { user, plan }
258
+ },
259
+ })
260
+ ```
261
+
262
+ - `ctx.memo`: a new memo for each request, created on first use.
263
+ - `ctx.cache` (with `cache`): the cache, logging with the request logger (request ID).
264
+
265
+ ### Memo without passing the context
266
+
267
+ Services deep in the code do not always receive `ctx`. With `memoScope: asyncLocalMemo`, each request runs in the scope of its memo ([`AsyncLocalStorage`](https://nodejs.org/api/async_context.html): Node.js, Bun, Deno, and Cloudflare Workers with the `nodejs_compat` flag), and a memoized loader works anywhere during the request:
268
+
269
+ ```ts
270
+ import { asyncLocalMemo, memoized } from '@dotpress/cache/als'
271
+
272
+ await createApp({ adapter, plugins: [cachePlugin({ cache, memoScope: asyncLocalMemo })] })
273
+
274
+ export const getUser = memoized(loadUser)
275
+
276
+ // A service, called by a middleware, a handler or another service
277
+ export const canEdit = async (userId: string, doc: Doc) => (await getUser(userId)).role === 'admin'
278
+ ```
279
+
280
+ Outside a request (an event listener, a job, a script), open a scope with `runWithMemo()`; `currentMemo()` returns the memo of the scope (for `set` and `delete`). Outside a scope, both throw an explicit error: there is no global memo.
281
+
282
+ ```ts
283
+ import { runWithMemo } from '@dotpress/cache/als'
284
+
285
+ export default defineListener(orderPaid, {
286
+ name: 'send-invoice',
287
+ handle: ({ payload }) => runWithMemo(() => sendInvoice(payload.orderId)), // one memo per message
288
+ })
289
+ ```
290
+
291
+ Without `/als`, create a memo per message: `const memo = createMemo()`.
292
+
293
+ ## Errors
294
+
295
+ | Error | Thrown when |
296
+ | --- | --- |
297
+ | `CacheError` | Base class of the errors of the package |
298
+ | `MemoBatchError` | `batch` returned values that do not match the keys (`memo`: name of the memo) |
299
+ | `CacheTimeoutError` | A load exceeded `hardTimeout`, without stale value to serve (`entry`, `key`, `timeout`) |
300
+ | `CacheStoreError` | The store failed or timed out, with `failOpen: false` (`store`, `operation`, `key`, `cause`); also passed to `onError` |
301
+
302
+ Invalid options and arguments throw a `TypeError` that names the option.
303
+
304
+ ## License
305
+
306
+ MIT
package/dist/als.d.ts ADDED
@@ -0,0 +1,36 @@
1
+ import { Memo, MemoDefinition } from './index.js';
2
+ import { MemoScope } from './plugin.js';
3
+ import './duration-2mZnG0_i.js';
4
+ import './store-D0BfLqyM.js';
5
+ import '@dotpress/core';
6
+
7
+ /**
8
+ * Runs a function inside the scope of a memo (a new one by default): the
9
+ * memoized loaders called during its run, awaits included, share it.
10
+ *
11
+ * ```ts
12
+ * // A listener, a job, a script
13
+ * await runWithMemo(() => sendInvoices(customerId))
14
+ * ```
15
+ */
16
+ declare const runWithMemo: <T>(fn: () => T, memo?: Memo) => T;
17
+ /** The memo of the current scope; throws outside a scope. */
18
+ declare const currentMemo: () => Memo;
19
+ /**
20
+ * A function that loads through the memo of the current scope, without
21
+ * passing a context around.
22
+ *
23
+ * ```ts
24
+ * export const getUser = memoized(loadUser)
25
+ * // anywhere during a request or a runWithMemo() call
26
+ * const user = await getUser(id)
27
+ * ```
28
+ */
29
+ declare const memoized: <A extends readonly unknown[], T>(definition: MemoDefinition<A, T>) => ((...args: A) => Promise<T>);
30
+ /**
31
+ * Scope of `cachePlugin({ memoScope: asyncLocalMemo })`: each request runs
32
+ * with `ctx.memo` as the memo of the scope.
33
+ */
34
+ declare const asyncLocalMemo: MemoScope;
35
+
36
+ export { asyncLocalMemo, currentMemo, memoized, runWithMemo };
package/dist/als.js ADDED
@@ -0,0 +1,35 @@
1
+ import {
2
+ createMemo
3
+ } from "./chunk-6ZNJJARH.js";
4
+
5
+ // src/als.ts
6
+ import { AsyncLocalStorage } from "node:async_hooks";
7
+ var storage = new AsyncLocalStorage();
8
+ var runWithMemo = (fn, memo = createMemo()) => {
9
+ if (typeof fn !== "function") {
10
+ throw new TypeError("runWithMemo(): expected a function.");
11
+ }
12
+ return storage.run(memo, fn);
13
+ };
14
+ var currentMemo = () => {
15
+ const memo = storage.getStore();
16
+ if (!memo) {
17
+ throw new Error(
18
+ "No memo scope: run the code with runWithMemo(), or install cachePlugin({ memoScope: asyncLocalMemo })."
19
+ );
20
+ }
21
+ return memo;
22
+ };
23
+ var memoized = (definition) => {
24
+ return (...args) => currentMemo()(definition, ...args);
25
+ };
26
+ var asyncLocalMemo = Object.freeze({
27
+ run: (memo, next) => storage.run(memo, next)
28
+ });
29
+ export {
30
+ asyncLocalMemo,
31
+ currentMemo,
32
+ memoized,
33
+ runWithMemo
34
+ };
35
+ //# sourceMappingURL=als.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/als.ts"],"sourcesContent":["// AsyncLocalStorage: Node.js, Bun, Deno, and Cloudflare Workers with the\n// nodejs_compat (or nodejs_als) compatibility flag.\n/// <reference types=\"node\" />\nimport { AsyncLocalStorage } from 'node:async_hooks'\nimport { createMemo, type Memo, type MemoDefinition } from './memo.js'\nimport type { MemoScope } from './plugin.js'\n\nconst storage = new AsyncLocalStorage<Memo>()\n\n/**\n * Runs a function inside the scope of a memo (a new one by default): the\n * memoized loaders called during its run, awaits included, share it.\n *\n * ```ts\n * // A listener, a job, a script\n * await runWithMemo(() => sendInvoices(customerId))\n * ```\n */\nexport const runWithMemo = <T>(fn: () => T, memo: Memo = createMemo()): T => {\n if (typeof fn !== 'function') {\n throw new TypeError('runWithMemo(): expected a function.')\n }\n return storage.run(memo, fn)\n}\n\n/** The memo of the current scope; throws outside a scope. */\nexport const currentMemo = (): Memo => {\n const memo = storage.getStore()\n if (!memo) {\n throw new Error(\n 'No memo scope: run the code with runWithMemo(), or install cachePlugin({ memoScope: asyncLocalMemo }).'\n )\n }\n return memo\n}\n\n/**\n * A function that loads through the memo of the current scope, without\n * passing a context around.\n *\n * ```ts\n * export const getUser = memoized(loadUser)\n * // anywhere during a request or a runWithMemo() call\n * const user = await getUser(id)\n * ```\n */\nexport const memoized = <A extends readonly unknown[], T>(\n definition: MemoDefinition<A, T>\n): ((...args: A) => Promise<T>) => {\n return (...args: A) => currentMemo()(definition, ...args)\n}\n\n/**\n * Scope of `cachePlugin({ memoScope: asyncLocalMemo })`: each request runs\n * with `ctx.memo` as the memo of the scope.\n */\nexport const asyncLocalMemo: MemoScope = Object.freeze({\n run: <T>(memo: Memo, next: () => Promise<T>) => storage.run(memo, next),\n})\n"],"mappings":";;;;;AAGA,SAAS,yBAAyB;AAIlC,IAAM,UAAU,IAAI,kBAAwB;AAWrC,IAAM,cAAc,CAAI,IAAa,OAAa,WAAW,MAAS;AAC3E,MAAI,OAAO,OAAO,YAAY;AAC5B,UAAM,IAAI,UAAU,qCAAqC;AAAA,EAC3D;AACA,SAAO,QAAQ,IAAI,MAAM,EAAE;AAC7B;AAGO,IAAM,cAAc,MAAY;AACrC,QAAM,OAAO,QAAQ,SAAS;AAC9B,MAAI,CAAC,MAAM;AACT,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;AAYO,IAAM,WAAW,CACtB,eACiC;AACjC,SAAO,IAAI,SAAY,YAAY,EAAE,YAAY,GAAG,IAAI;AAC1D;AAMO,IAAM,iBAA4B,OAAO,OAAO;AAAA,EACrD,KAAK,CAAI,MAAY,SAA2B,QAAQ,IAAI,MAAM,IAAI;AACxE,CAAC;","names":[]}
@@ -0,0 +1,27 @@
1
+ // src/duration.ts
2
+ var UNITS = {
3
+ ms: 1,
4
+ s: 1e3,
5
+ m: 6e4,
6
+ h: 36e5,
7
+ d: 864e5
8
+ };
9
+ var PATTERN = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/;
10
+ var toMilliseconds = (label, value) => {
11
+ if (typeof value === "number") {
12
+ if (Number.isFinite(value) && value >= 0) return Math.round(value * 1e3);
13
+ } else if (typeof value === "string") {
14
+ const match = PATTERN.exec(value);
15
+ if (match) {
16
+ return Math.round(Number(match[1]) * UNITS[match[2]]);
17
+ }
18
+ }
19
+ throw new TypeError(
20
+ `${label} must be a number of seconds, or a duration like '30s', '10m', '1h' or '7d'.`
21
+ );
22
+ };
23
+
24
+ export {
25
+ toMilliseconds
26
+ };
27
+ //# sourceMappingURL=chunk-2IEBMYSF.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/duration.ts"],"sourcesContent":["/**\n * A duration: a number of seconds, or a string with a unit: `'500ms'`,\n * `'30s'`, `'10m'`, `'1h'`, `'7d'`.\n */\nexport type Duration = number | `${number}${DurationUnit}`\n\ntype DurationUnit = 'ms' | 's' | 'm' | 'h' | 'd'\n\nconst UNITS: Readonly<Record<DurationUnit, number>> = {\n ms: 1,\n s: 1000,\n m: 60_000,\n h: 3_600_000,\n d: 86_400_000,\n}\n\nconst PATTERN = /^(\\d+(?:\\.\\d+)?)(ms|s|m|h|d)$/\n\n/**\n * Converts a duration to whole milliseconds, or throws a `TypeError` that\n * names the option.\n */\nexport const toMilliseconds = (label: string, value: unknown): number => {\n if (typeof value === 'number') {\n if (Number.isFinite(value) && value >= 0) return Math.round(value * 1000)\n } else if (typeof value === 'string') {\n const match = PATTERN.exec(value)\n if (match) {\n return Math.round(Number(match[1]) * UNITS[match[2] as DurationUnit])\n }\n }\n throw new TypeError(\n `${label} must be a number of seconds, or a duration like '30s', '10m', '1h' or '7d'.`\n )\n}\n"],"mappings":";AAQA,IAAM,QAAgD;AAAA,EACpD,IAAI;AAAA,EACJ,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AACL;AAEA,IAAM,UAAU;AAMT,IAAM,iBAAiB,CAAC,OAAe,UAA2B;AACvE,MAAI,OAAO,UAAU,UAAU;AAC7B,QAAI,OAAO,SAAS,KAAK,KAAK,SAAS,EAAG,QAAO,KAAK,MAAM,QAAQ,GAAI;AAAA,EAC1E,WAAW,OAAO,UAAU,UAAU;AACpC,UAAM,QAAQ,QAAQ,KAAK,KAAK;AAChC,QAAI,OAAO;AACT,aAAO,KAAK,MAAM,OAAO,MAAM,CAAC,CAAC,IAAI,MAAM,MAAM,CAAC,CAAiB,CAAC;AAAA,IACtE;AAAA,EACF;AACA,QAAM,IAAI;AAAA,IACR,GAAG,KAAK;AAAA,EACV;AACF;","names":[]}