@sdxc/cache 0.0.0-pre.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.md +21 -0
- package/README.md +263 -0
- package/dist/adapters/memory.d.ts +39 -0
- package/dist/adapters/memory.js +86 -0
- package/dist/adapters/worker-kv.d.ts +38 -0
- package/dist/adapters/worker-kv.js +101 -0
- package/dist/errors.d.ts +46 -0
- package/dist/errors.js +33 -0
- package/dist/index.d.ts +62 -0
- package/dist/index.js +9 -0
- package/dist/lib/attempt.d.ts +30 -0
- package/dist/lib/attempt.js +54 -0
- package/dist/lib/json.d.ts +27 -0
- package/dist/lib/json.js +56 -0
- package/dist/lib/ttl.d.ts +17 -0
- package/dist/lib/ttl.js +23 -0
- package/dist/testing/conformance.d.ts +31 -0
- package/dist/testing/conformance.js +161 -0
- package/package.json +28 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sergio Xalambrí
|
|
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
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# @sdxc/cache
|
|
2
|
+
|
|
3
|
+
Cache contract with adapters for memory and Cloudflare KV.
|
|
4
|
+
|
|
5
|
+
One interface covers read, write, fetch and delete, and an adapter binds it to a store — an
|
|
6
|
+
in-process map for tests, Workers KV in production. Nothing throws: every call answers with a
|
|
7
|
+
`Result`, and each method takes the value's type on its own, so one instance serves a whole
|
|
8
|
+
namespace whatever mix of types goes into it.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm add @sdxc/cache
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Every call answers with a `Result` from
|
|
17
|
+
[`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result), which is where `isFailure` and
|
|
18
|
+
`isSuccess` come from. It installs alongside this package.
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
An instance is a store, not a type. Each method takes the value's type on its own, so one
|
|
23
|
+
cache serves a whole namespace whatever mix of types goes into it. Nothing throws — every
|
|
24
|
+
call answers with a `Result`:
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
import { WorkerKVCache } from "@sdxc/cache/worker-kv";
|
|
28
|
+
import { isFailure } from "@sdxc/result";
|
|
29
|
+
|
|
30
|
+
let cache = new WorkerKVCache(env.CACHE, { waitUntil: ctx.waitUntil.bind(ctx) });
|
|
31
|
+
|
|
32
|
+
let post = await cache.fetch(
|
|
33
|
+
`post:${id}`,
|
|
34
|
+
async () => db.query.posts.findFirst({ where: eq(posts.id, id) }),
|
|
35
|
+
{ ttl: "5 minutes" },
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
if (isFailure(post)) return notFound();
|
|
39
|
+
render(post.data);
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`fetch` takes the type from the loader, so nothing is annotated and nothing is cast. What
|
|
43
|
+
comes back is what reads back out of the store, which is not always what went in:
|
|
44
|
+
|
|
45
|
+
```typescript
|
|
46
|
+
let session = await cache.fetch("session", async () => ({ at: new Date() }));
|
|
47
|
+
// Result<{ at: string }, CacheError> — a Date is written as the string it serializes to
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Swap the adapter to cache in a test without a KV namespace or a Worker:
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
import { MemoryCache } from "@sdxc/cache/memory";
|
|
54
|
+
|
|
55
|
+
let cache = new MemoryCache();
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Nothing throws
|
|
59
|
+
|
|
60
|
+
Every method answers with a `Result<_, CacheError>` from
|
|
61
|
+
[`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result), and `CacheError.code` says
|
|
62
|
+
which of three things happened:
|
|
63
|
+
|
|
64
|
+
| Code | Means | Reached by |
|
|
65
|
+
| --------------- | -------------------------------------------------------- | ------------------------- |
|
|
66
|
+
| `unavailable` | The store could not be reached, or refused the operation | `read`, `write`, `delete` |
|
|
67
|
+
| `invalid_value` | JSON could not write the value, or read the entry stored | every method |
|
|
68
|
+
| `load_failed` | The loader a `fetch` was given failed | `fetch` |
|
|
69
|
+
|
|
70
|
+
`unavailable` is the one a caller can ignore: the value is simply not cached, and computing
|
|
71
|
+
it is the same recovery a miss asks for. A store failure is also recorded on the
|
|
72
|
+
invocation's log through [`@sdxc/logger`](https://www.npmjs.com/package/@sdxc/logger), so
|
|
73
|
+
discarding one still leaves a trace — store health is the package's own to report. A
|
|
74
|
+
`load_failed` is not logged, because it is the caller's and travels in the `Result`.
|
|
75
|
+
|
|
76
|
+
Every failure carries the `key` it happened on, and the original error as `cause`. So a
|
|
77
|
+
caller that would rather throw rethrows the cause and keeps its own error type:
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
let stored = await cache.fetch(key, load, { ttl: TTL });
|
|
81
|
+
if (isFailure(stored)) throw stored.error.cause ?? stored.error;
|
|
82
|
+
return stored.data;
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
**`fetch` absorbs everything the store does wrong**, because it can compute a value
|
|
86
|
+
instead: an unreachable store, an entry that is not JSON, and a refused write all still
|
|
87
|
+
answer with the value. So a `fetch` failure means the value could not be produced at all.
|
|
88
|
+
|
|
89
|
+
## API
|
|
90
|
+
|
|
91
|
+
### `Cache`
|
|
92
|
+
|
|
93
|
+
The contract both adapters implement.
|
|
94
|
+
|
|
95
|
+
#### `read<T>(key): Promise<Result<JSONSerialized<T> | null, CacheError>>`
|
|
96
|
+
|
|
97
|
+
Reads an entry, succeeding with `null` when it is missing or expired.
|
|
98
|
+
|
|
99
|
+
`T` is an assertion. What a store holds under a key is a fact about the past, and no
|
|
100
|
+
signature can check it, so the parameter states a claim rather than verifying one. It
|
|
101
|
+
defaults to `JSONValue` for a caller who would rather narrow the result.
|
|
102
|
+
|
|
103
|
+
A stored `null` succeeds with `null` too, indistinguishable from a miss. Reach for `fetch`
|
|
104
|
+
where a cached `null` has to count as a hit.
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
let hit = await cache.read<{ id: number }>("post:1");
|
|
108
|
+
if (isSuccess(hit) && hit.data !== null) render(hit.data);
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
#### `write<T>(key, value, options?): Promise<Result<void, CacheError>>`
|
|
112
|
+
|
|
113
|
+
Writes an entry, replacing any current value for the key. Succeeds once the value is
|
|
114
|
+
readable.
|
|
115
|
+
|
|
116
|
+
- `options.ttl` — how long the entry stays readable, as whole seconds or a duration string
|
|
117
|
+
such as `"1 hour"`. Omitted, the entry never expires.
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
await cache.write("post:1", { id: 1, publishedAt: new Date() }, { ttl: "1 hour" });
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
#### `fetch<T>(key, load, options?): Promise<Result<JSONSerialized<T>, CacheError>>`
|
|
124
|
+
|
|
125
|
+
Returns the stored entry, computing and storing it on a miss. The type comes from `load`,
|
|
126
|
+
and the value is returned as it reads back, so a hit and a miss answer with the same type.
|
|
127
|
+
|
|
128
|
+
A stored `null` is a hit here, unlike in `read`.
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
let feed = await cache.fetch("feed", async () => buildFeed(), { ttl: "5 minutes" });
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
#### `delete(key): Promise<Result<void, CacheError>>`
|
|
135
|
+
|
|
136
|
+
Removes an entry, succeeding whether or not it exists.
|
|
137
|
+
|
|
138
|
+
### `CacheError`
|
|
139
|
+
|
|
140
|
+
The failure inside every `Result`. Carries `code`, the `key` it happened on, and the
|
|
141
|
+
original error as `cause`.
|
|
142
|
+
|
|
143
|
+
### `MemoryCache`
|
|
144
|
+
|
|
145
|
+
`@sdxc/cache/memory` — holds entries in a map for the life of the instance.
|
|
146
|
+
|
|
147
|
+
It is not a mock. It serializes on write and parses on read, so a `Date` comes back as a
|
|
148
|
+
string here exactly as it would from a remote store, and it passes the same conformance
|
|
149
|
+
suite every other adapter does. A value read back is a copy, so mutating it changes
|
|
150
|
+
nothing that is stored.
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
new MemoryCache();
|
|
154
|
+
new MemoryCache({ now: () => clock }); // expire an entry without waiting for one
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
- `options.now` — reads the current time in milliseconds. Defaults to `Date.now`.
|
|
158
|
+
|
|
159
|
+
### `WorkerKVCache`
|
|
160
|
+
|
|
161
|
+
`@sdxc/cache/worker-kv` — reads and writes entries in a KV namespace.
|
|
162
|
+
|
|
163
|
+
```typescript
|
|
164
|
+
new WorkerKVCache(env.CACHE);
|
|
165
|
+
new WorkerKVCache(env.CACHE, { waitUntil: ctx.waitUntil.bind(ctx) });
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
- `options.waitUntil` — extends the invocation past the response. Given one, a write is
|
|
169
|
+
handed over and the caller is not made to wait for KV.
|
|
170
|
+
|
|
171
|
+
A deferred write is still readable the moment it succeeds: the value is held on the
|
|
172
|
+
instance until the put lands, and an instance is per request, which is the scope over
|
|
173
|
+
which reading your own writes is both achievable and worth anything. Two writes to one key
|
|
174
|
+
land in the order they were made, and a `delete` waits for a put still in flight.
|
|
175
|
+
|
|
176
|
+
KV refuses a TTL under 60 seconds, so a shorter one leaves the entry unwritten and reports
|
|
177
|
+
`unavailable`. A caller wanting a shorter lifetime than that wants something other than a
|
|
178
|
+
cache.
|
|
179
|
+
|
|
180
|
+
### `conformance(options)`
|
|
181
|
+
|
|
182
|
+
`@sdxc/cache/conformance` — registers the suite that says what a cache is, as Vitest tests
|
|
183
|
+
against whatever you construct. Run it against a new adapter and it is a cache, or it is
|
|
184
|
+
not.
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
import { conformance } from "@sdxc/cache/conformance";
|
|
188
|
+
|
|
189
|
+
conformance({
|
|
190
|
+
name: "MyCache",
|
|
191
|
+
create: () => new MyCache(),
|
|
192
|
+
expire: (seconds) => clock.advance(seconds),
|
|
193
|
+
});
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
- `name` — labels the registered suite.
|
|
197
|
+
- `create` — builds the cache under test, called for every test so a mutable adapter starts
|
|
198
|
+
clean.
|
|
199
|
+
- `expire` — moves the adapter's clock forward. An adapter that can only expire in real
|
|
200
|
+
time leaves it out, and the expiry tests are registered only for the adapters that can.
|
|
201
|
+
|
|
202
|
+
## Pattern: keying by prefix
|
|
203
|
+
|
|
204
|
+
Prefix a key by what it holds, so entries are recognizable in a namespace shared with other
|
|
205
|
+
caches and a related group can be reasoned about together:
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
await cache.write(`post:${id}`, post, { ttl: "1 hour" });
|
|
209
|
+
await cache.write(`feed:${locale}`, feed, { ttl: "5 minutes" });
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
## Pattern: a cache in a test
|
|
213
|
+
|
|
214
|
+
Take `Cache` where a cache is used, and the test supplies a memory one:
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
async function publishedPosts(cache: Cache): Promise<Post[]> {
|
|
218
|
+
let posts = await cache.fetch("posts", async () => db.query.posts.findMany(), {
|
|
219
|
+
ttl: "5 minutes",
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
return isFailure(posts) ? [] : posts.data;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
test("reads the posts once", async () => {
|
|
226
|
+
let cache = new MemoryCache();
|
|
227
|
+
|
|
228
|
+
await publishedPosts(cache);
|
|
229
|
+
await publishedPosts(cache);
|
|
230
|
+
|
|
231
|
+
expect(queries).toHaveLength(1);
|
|
232
|
+
});
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
## Versioning
|
|
236
|
+
|
|
237
|
+
Releases are dated rather than semantic. A version is the UTC date it was published,
|
|
238
|
+
written `YYYY.M.D`, so `2026.9.4` is the release from 4 September 2026. At most one
|
|
239
|
+
release goes out per day.
|
|
240
|
+
|
|
241
|
+
Those numbers say when, not what: a later date means a later release and carries no
|
|
242
|
+
compatibility promise. Any release may change or remove an export.
|
|
243
|
+
|
|
244
|
+
Depend on one exact date, and move it when you are ready to take the change:
|
|
245
|
+
|
|
246
|
+
```json
|
|
247
|
+
{
|
|
248
|
+
"dependencies": {
|
|
249
|
+
"@sdxc/cache": "2026.9.4"
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
A caret or tilde range reads the date as major, minor and patch, so it accepts every
|
|
255
|
+
later release in the same year. An exact version keeps the upgrade yours to schedule.
|
|
256
|
+
|
|
257
|
+
## License
|
|
258
|
+
|
|
259
|
+
MIT
|
|
260
|
+
|
|
261
|
+
## Author
|
|
262
|
+
|
|
263
|
+
[Sergio Xalambrí](https://sergiodxa.com)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A cache whose store is a map, for tests and for anything wanting a cache
|
|
3
|
+
* without a platform behind it. It serializes what it holds and expires against
|
|
4
|
+
* an injectable clock, so it answers exactly as a remote store would.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { Result } from "@sdxc/result";
|
|
10
|
+
import type { JSONSerialized, JSONValue } from "@sdxc/types";
|
|
11
|
+
import type { Cache, CacheError, CacheWriteOptions } from "../index.js";
|
|
12
|
+
/** How a memory cache tells the time. */
|
|
13
|
+
export interface MemoryCacheOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Reads the current time in milliseconds. A test supplies its own so an entry
|
|
16
|
+
* can be expired without waiting for one.
|
|
17
|
+
*
|
|
18
|
+
* @default Date.now
|
|
19
|
+
*/
|
|
20
|
+
now?: () => number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Holds entries in a map for the life of the instance.
|
|
24
|
+
*
|
|
25
|
+
* It is not a mock: it serializes on write and parses on read, so a `Date` comes
|
|
26
|
+
* back as a string here exactly as it would from a remote store, and it passes
|
|
27
|
+
* the same conformance suite every other adapter does.
|
|
28
|
+
*/
|
|
29
|
+
export declare class MemoryCache implements Cache {
|
|
30
|
+
#private;
|
|
31
|
+
/**
|
|
32
|
+
* @param options How the cache tells the time.
|
|
33
|
+
*/
|
|
34
|
+
constructor({ now }?: MemoryCacheOptions);
|
|
35
|
+
read<T = JSONValue>(key: string): Promise<Result<JSONSerialized<T> | null, CacheError>>;
|
|
36
|
+
write<T>(key: string, value: T, options?: CacheWriteOptions): Promise<Result<void, CacheError>>;
|
|
37
|
+
fetch<T>(key: string, load: () => Promise<T>, options?: CacheWriteOptions): Promise<Result<JSONSerialized<T>, CacheError>>;
|
|
38
|
+
delete(key: string): Promise<Result<void, CacheError>>;
|
|
39
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A cache whose store is a map, for tests and for anything wanting a cache
|
|
3
|
+
* without a platform behind it. It serializes what it holds and expires against
|
|
4
|
+
* an injectable clock, so it answers exactly as a remote store would.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { isFailure, isSuccess, success } from "@sdxc/result";
|
|
10
|
+
import { attemptLoad } from "../lib/attempt.js";
|
|
11
|
+
import { parse, serialize } from "../lib/json.js";
|
|
12
|
+
import { ttlSeconds } from "../lib/ttl.js";
|
|
13
|
+
/** Milliseconds in the second a TTL is counted in. */
|
|
14
|
+
const SECOND_MS = 1000;
|
|
15
|
+
/**
|
|
16
|
+
* Holds entries in a map for the life of the instance.
|
|
17
|
+
*
|
|
18
|
+
* It is not a mock: it serializes on write and parses on read, so a `Date` comes
|
|
19
|
+
* back as a string here exactly as it would from a remote store, and it passes
|
|
20
|
+
* the same conformance suite every other adapter does.
|
|
21
|
+
*/
|
|
22
|
+
export class MemoryCache {
|
|
23
|
+
#entries = new Map();
|
|
24
|
+
#now;
|
|
25
|
+
/**
|
|
26
|
+
* @param options How the cache tells the time.
|
|
27
|
+
*/
|
|
28
|
+
constructor({ now = Date.now } = {}) {
|
|
29
|
+
this.#now = now;
|
|
30
|
+
}
|
|
31
|
+
async read(key) {
|
|
32
|
+
let text = this.#read(key);
|
|
33
|
+
if (text === null)
|
|
34
|
+
return success(null);
|
|
35
|
+
return parse(key, text);
|
|
36
|
+
}
|
|
37
|
+
async write(key, value, options = {}) {
|
|
38
|
+
let text = serialize(key, value);
|
|
39
|
+
if (isFailure(text))
|
|
40
|
+
return text;
|
|
41
|
+
this.#write(key, text.data, options);
|
|
42
|
+
return success(undefined);
|
|
43
|
+
}
|
|
44
|
+
async fetch(key, load, options = {}) {
|
|
45
|
+
let stored = this.#read(key);
|
|
46
|
+
if (stored !== null) {
|
|
47
|
+
let hit = parse(key, stored);
|
|
48
|
+
if (isSuccess(hit))
|
|
49
|
+
return hit;
|
|
50
|
+
}
|
|
51
|
+
let loaded = await attemptLoad(key, load);
|
|
52
|
+
if (isFailure(loaded))
|
|
53
|
+
return loaded;
|
|
54
|
+
let text = serialize(key, loaded.data);
|
|
55
|
+
if (isFailure(text))
|
|
56
|
+
return text;
|
|
57
|
+
this.#write(key, text.data, options);
|
|
58
|
+
return parse(key, text.data);
|
|
59
|
+
}
|
|
60
|
+
async delete(key) {
|
|
61
|
+
this.#entries.delete(key);
|
|
62
|
+
return success(undefined);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The stored text, or `null` when nothing readable is held. An expired entry is
|
|
66
|
+
* dropped as it is found, which is the only sweep the map gets.
|
|
67
|
+
*/
|
|
68
|
+
#read(key) {
|
|
69
|
+
let entry = this.#entries.get(key);
|
|
70
|
+
if (entry === undefined)
|
|
71
|
+
return null;
|
|
72
|
+
if (entry.expiresAt !== undefined && this.#now() >= entry.expiresAt) {
|
|
73
|
+
this.#entries.delete(key);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return entry.text;
|
|
77
|
+
}
|
|
78
|
+
/** Stores the text against the expiry the TTL resolves to. */
|
|
79
|
+
#write(key, text, { ttl }) {
|
|
80
|
+
let seconds = ttlSeconds(ttl);
|
|
81
|
+
this.#entries.set(key, {
|
|
82
|
+
text,
|
|
83
|
+
expiresAt: seconds === undefined ? undefined : this.#now() + seconds * SECOND_MS,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A cache over a Cloudflare KV namespace, which is where entries go in
|
|
3
|
+
* production. Given a `waitUntil` it hands the put to it and answers from a
|
|
4
|
+
* buffer meanwhile, so a miss never waits on KV and a write is readable regardless.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { Result } from "@sdxc/result";
|
|
10
|
+
import type { JSONSerialized, JSONValue } from "@sdxc/types";
|
|
11
|
+
import type { Cache, CacheError, CacheWriteOptions } from "../index.js";
|
|
12
|
+
/** How a Worker KV cache defers its writes. */
|
|
13
|
+
export interface WorkerKVCacheOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Extends the invocation past the response so a put can finish after it. Given
|
|
16
|
+
* one, a write is handed over and the caller is not made to wait for KV.
|
|
17
|
+
*/
|
|
18
|
+
waitUntil?: (promise: Promise<unknown>) => void;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Reads and writes entries in a KV namespace.
|
|
22
|
+
*
|
|
23
|
+
* KV refuses a TTL under 60 seconds, so a shorter one leaves the entry unwritten
|
|
24
|
+
* and reports `unavailable`; a caller wanting a shorter lifetime than that wants
|
|
25
|
+
* something other than a cache.
|
|
26
|
+
*/
|
|
27
|
+
export declare class WorkerKVCache implements Cache {
|
|
28
|
+
#private;
|
|
29
|
+
/**
|
|
30
|
+
* @param kv The namespace entries are stored in.
|
|
31
|
+
* @param options How writes are deferred.
|
|
32
|
+
*/
|
|
33
|
+
constructor(kv: KVNamespace, { waitUntil }?: WorkerKVCacheOptions);
|
|
34
|
+
read<T = JSONValue>(key: string): Promise<Result<JSONSerialized<T> | null, CacheError>>;
|
|
35
|
+
write<T>(key: string, value: T, options?: CacheWriteOptions): Promise<Result<void, CacheError>>;
|
|
36
|
+
fetch<T>(key: string, load: () => Promise<T>, options?: CacheWriteOptions): Promise<Result<JSONSerialized<T>, CacheError>>;
|
|
37
|
+
delete(key: string): Promise<Result<void, CacheError>>;
|
|
38
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A cache over a Cloudflare KV namespace, which is where entries go in
|
|
3
|
+
* production. Given a `waitUntil` it hands the put to it and answers from a
|
|
4
|
+
* buffer meanwhile, so a miss never waits on KV and a write is readable regardless.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { isFailure, isSuccess, success } from "@sdxc/result";
|
|
10
|
+
import { attempt, attemptLoad } from "../lib/attempt.js";
|
|
11
|
+
import { parse, serialize } from "../lib/json.js";
|
|
12
|
+
import { ttlSeconds } from "../lib/ttl.js";
|
|
13
|
+
/**
|
|
14
|
+
* Reads and writes entries in a KV namespace.
|
|
15
|
+
*
|
|
16
|
+
* KV refuses a TTL under 60 seconds, so a shorter one leaves the entry unwritten
|
|
17
|
+
* and reports `unavailable`; a caller wanting a shorter lifetime than that wants
|
|
18
|
+
* something other than a cache.
|
|
19
|
+
*/
|
|
20
|
+
export class WorkerKVCache {
|
|
21
|
+
#kv;
|
|
22
|
+
#waitUntil;
|
|
23
|
+
/**
|
|
24
|
+
* Values written while their put is still in flight, so a read on this instance
|
|
25
|
+
* sees what was just written. The instance is per request, which is the scope
|
|
26
|
+
* over which reading your own writes is both achievable and worth anything.
|
|
27
|
+
*/
|
|
28
|
+
#pending = new Map();
|
|
29
|
+
/**
|
|
30
|
+
* @param kv The namespace entries are stored in.
|
|
31
|
+
* @param options How writes are deferred.
|
|
32
|
+
*/
|
|
33
|
+
constructor(kv, { waitUntil } = {}) {
|
|
34
|
+
this.#kv = kv;
|
|
35
|
+
this.#waitUntil = waitUntil;
|
|
36
|
+
}
|
|
37
|
+
async read(key) {
|
|
38
|
+
let text = await this.#load(key);
|
|
39
|
+
if (isFailure(text))
|
|
40
|
+
return text;
|
|
41
|
+
if (text.data === null)
|
|
42
|
+
return success(null);
|
|
43
|
+
return parse(key, text.data);
|
|
44
|
+
}
|
|
45
|
+
async write(key, value, options = {}) {
|
|
46
|
+
let text = serialize(key, value);
|
|
47
|
+
if (isFailure(text))
|
|
48
|
+
return text;
|
|
49
|
+
return this.#store(key, text.data, options);
|
|
50
|
+
}
|
|
51
|
+
async fetch(key, load, options = {}) {
|
|
52
|
+
let stored = await this.#load(key);
|
|
53
|
+
if (isSuccess(stored) && stored.data !== null) {
|
|
54
|
+
let hit = parse(key, stored.data);
|
|
55
|
+
if (isSuccess(hit))
|
|
56
|
+
return hit;
|
|
57
|
+
}
|
|
58
|
+
let loaded = await attemptLoad(key, load);
|
|
59
|
+
if (isFailure(loaded))
|
|
60
|
+
return loaded;
|
|
61
|
+
let text = serialize(key, loaded.data);
|
|
62
|
+
if (isFailure(text))
|
|
63
|
+
return text;
|
|
64
|
+
await this.#store(key, text.data, options);
|
|
65
|
+
return parse(key, text.data);
|
|
66
|
+
}
|
|
67
|
+
async delete(key) {
|
|
68
|
+
let inflight = this.#pending.get(key);
|
|
69
|
+
this.#pending.delete(key);
|
|
70
|
+
if (inflight !== undefined)
|
|
71
|
+
await inflight.settled;
|
|
72
|
+
return attempt("delete", key, () => this.#kv.delete(key));
|
|
73
|
+
}
|
|
74
|
+
/** The stored text, from the buffer when a put is still in flight. */
|
|
75
|
+
async #load(key) {
|
|
76
|
+
let buffered = this.#pending.get(key);
|
|
77
|
+
if (buffered !== undefined)
|
|
78
|
+
return success(buffered.text);
|
|
79
|
+
return attempt("read", key, () => this.#kv.get(key, "text"));
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Stores the text, returning once it is readable: immediately when the put is
|
|
83
|
+
* deferred, since the buffer answers for it until KV does. A deferred put is
|
|
84
|
+
* chained after any put already in flight for the key, so two writes land in order.
|
|
85
|
+
*/
|
|
86
|
+
async #store(key, text, { ttl }) {
|
|
87
|
+
let put = () => this.#kv.put(key, text, { expirationTtl: ttlSeconds(ttl) });
|
|
88
|
+
if (this.#waitUntil === undefined)
|
|
89
|
+
return attempt("write", key, put);
|
|
90
|
+
let previous = this.#pending.get(key)?.settled;
|
|
91
|
+
let settled = (previous ?? Promise.resolve()).then(async () => {
|
|
92
|
+
await attempt("write", key, put);
|
|
93
|
+
});
|
|
94
|
+
this.#pending.set(key, { text, settled });
|
|
95
|
+
this.#waitUntil(settled.finally(() => {
|
|
96
|
+
if (this.#pending.get(key)?.settled === settled)
|
|
97
|
+
this.#pending.delete(key);
|
|
98
|
+
}));
|
|
99
|
+
return success(undefined);
|
|
100
|
+
}
|
|
101
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single failure type every cache call reports: one normalized code, the
|
|
3
|
+
* key it happened on, and the original error as `cause`. It is the error inside
|
|
4
|
+
* every `Result` the contract returns, so no cache path throws.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Normalized reason a cache call failed. A caller branches on this; the original
|
|
11
|
+
* error travels alongside as `cause`.
|
|
12
|
+
*/
|
|
13
|
+
export type CacheErrorCode =
|
|
14
|
+
/** The store could not be reached, or refused the operation. */
|
|
15
|
+
"unavailable"
|
|
16
|
+
/** JSON could not write the value handed over, or read the entry stored. */
|
|
17
|
+
| "invalid_value"
|
|
18
|
+
/** The loader a `fetch` was given failed, so there is no value to answer with. */
|
|
19
|
+
| "load_failed";
|
|
20
|
+
/** What a cache states about a failure when it constructs the error. */
|
|
21
|
+
export interface CacheErrorOptions extends ErrorOptions {
|
|
22
|
+
code: CacheErrorCode;
|
|
23
|
+
/** The entry the call was made on. */
|
|
24
|
+
key: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Failure carried by every cache `Result`.
|
|
28
|
+
*
|
|
29
|
+
* `unavailable` is the one a caller can ignore: the value is simply not cached,
|
|
30
|
+
* and computing it is the same recovery a miss asks for. `load_failed` is the
|
|
31
|
+
* one that matters, because it means the value could not be produced at all.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* failure(new CacheError("KV refused the put", { code: "unavailable", key }));
|
|
35
|
+
*/
|
|
36
|
+
export declare class CacheError extends Error {
|
|
37
|
+
name: string;
|
|
38
|
+
readonly code: CacheErrorCode;
|
|
39
|
+
/** The entry the failing call was made on. */
|
|
40
|
+
readonly key: string;
|
|
41
|
+
/**
|
|
42
|
+
* @param message What went wrong, for a log or a rethrow.
|
|
43
|
+
* @param options The code, the key, and the original error as `cause`.
|
|
44
|
+
*/
|
|
45
|
+
constructor(message: string, { code, key, ...options }: CacheErrorOptions);
|
|
46
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single failure type every cache call reports: one normalized code, the
|
|
3
|
+
* key it happened on, and the original error as `cause`. It is the error inside
|
|
4
|
+
* every `Result` the contract returns, so no cache path throws.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Failure carried by every cache `Result`.
|
|
11
|
+
*
|
|
12
|
+
* `unavailable` is the one a caller can ignore: the value is simply not cached,
|
|
13
|
+
* and computing it is the same recovery a miss asks for. `load_failed` is the
|
|
14
|
+
* one that matters, because it means the value could not be produced at all.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* failure(new CacheError("KV refused the put", { code: "unavailable", key }));
|
|
18
|
+
*/
|
|
19
|
+
export class CacheError extends Error {
|
|
20
|
+
name = "CacheError";
|
|
21
|
+
code;
|
|
22
|
+
/** The entry the failing call was made on. */
|
|
23
|
+
key;
|
|
24
|
+
/**
|
|
25
|
+
* @param message What went wrong, for a log or a rethrow.
|
|
26
|
+
* @param options The code, the key, and the original error as `cause`.
|
|
27
|
+
*/
|
|
28
|
+
constructor(message, { code, key, ...options }) {
|
|
29
|
+
super(message, options);
|
|
30
|
+
this.code = code;
|
|
31
|
+
this.key = key;
|
|
32
|
+
}
|
|
33
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cache contract every adapter implements: read, write, fetch and delete
|
|
3
|
+
* over string keys. The value's type is taken per call rather than per instance,
|
|
4
|
+
* so one instance serves a whole store whatever mix of types goes into it.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { DurationInput } from "@sdxc/duration";
|
|
10
|
+
import type { Result } from "@sdxc/result";
|
|
11
|
+
import type { JSONSerialized, JSONValue } from "@sdxc/types";
|
|
12
|
+
import type { CacheError } from "./errors.js";
|
|
13
|
+
export type { CacheErrorCode, CacheErrorOptions } from "./errors.js";
|
|
14
|
+
export { CacheError } from "./errors.js";
|
|
15
|
+
/** Expiration a write carries. */
|
|
16
|
+
export interface CacheWriteOptions {
|
|
17
|
+
/**
|
|
18
|
+
* A bare number is whole seconds — the unit a store counts — while a duration
|
|
19
|
+
* string states its own unit. Omitted means the entry never expires.
|
|
20
|
+
*/
|
|
21
|
+
ttl?: DurationInput;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* A key/value store holding values a caller would rather not compute twice.
|
|
25
|
+
*
|
|
26
|
+
* Nothing here throws: every method answers with a `Result`, and a store that
|
|
27
|
+
* cannot be reached is an `unavailable` failure a caller is free to treat as a
|
|
28
|
+
* miss. A store failure is also recorded on the invocation's log, so discarding
|
|
29
|
+
* one still leaves a trace of it.
|
|
30
|
+
*
|
|
31
|
+
* A value is held as JSON, and the caller is trusted to hand over one JSON can
|
|
32
|
+
* write. The type is not constrained to `JSONSerializable`, because as a constraint
|
|
33
|
+
* it rejects an ordinary interface and any field typed `unknown` — both of which
|
|
34
|
+
* cache perfectly well. A value JSON genuinely cannot write is `invalid_value`.
|
|
35
|
+
*/
|
|
36
|
+
export interface Cache {
|
|
37
|
+
/**
|
|
38
|
+
* Reads an entry, succeeding with `null` when it is missing or expired.
|
|
39
|
+
*
|
|
40
|
+
* `T` is an assertion: what a store holds under a key is a fact about the past,
|
|
41
|
+
* and no signature can check it. A stored `null` succeeds with `null` too, so
|
|
42
|
+
* reach for {@link Cache.fetch} where a cached `null` has to count as a hit.
|
|
43
|
+
*/
|
|
44
|
+
read<T = JSONValue>(key: string): Promise<Result<JSONSerialized<T> | null, CacheError>>;
|
|
45
|
+
/** Writes an entry, replacing any current value for the key. */
|
|
46
|
+
write<T>(key: string, value: T, options?: CacheWriteOptions): Promise<Result<void, CacheError>>;
|
|
47
|
+
/**
|
|
48
|
+
* Returns the stored entry, computing and storing it on a miss.
|
|
49
|
+
*
|
|
50
|
+
* The value is returned as it reads back, so a hit and a miss answer with the
|
|
51
|
+
* same type. A stored `null` is a hit, unlike in {@link Cache.read}.
|
|
52
|
+
*
|
|
53
|
+
* Everything the store does wrong is absorbed here, because a value can be
|
|
54
|
+
* computed instead: an unreachable store, an entry that is not JSON, and a
|
|
55
|
+
* refused write all still answer with the value. So a failure means the value
|
|
56
|
+
* could not be produced — `load_failed` when the loader failed, `invalid_value`
|
|
57
|
+
* when what it produced cannot be written.
|
|
58
|
+
*/
|
|
59
|
+
fetch<T>(key: string, load: () => Promise<T>, options?: CacheWriteOptions): Promise<Result<JSONSerialized<T>, CacheError>>;
|
|
60
|
+
/** Removes an entry, succeeding whether or not it exists. */
|
|
61
|
+
delete(key: string): Promise<Result<void, CacheError>>;
|
|
62
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cache contract every adapter implements: read, write, fetch and delete
|
|
3
|
+
* over string keys. The value's type is taken per call rather than per instance,
|
|
4
|
+
* so one instance serves a whole store whatever mix of types goes into it.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
export { CacheError } from "./errors.js";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs the two kinds of work a cache does that can fail: a store operation, and
|
|
3
|
+
* the loader a `fetch` was given. A store failure is recorded on the invocation's
|
|
4
|
+
* log, because store health is the package's own to report; a loader's is not.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { Result } from "@sdxc/result";
|
|
10
|
+
import { CacheError } from "../errors.js";
|
|
11
|
+
/** The operations a store performs, which name the log entry a failure writes. */
|
|
12
|
+
export type StoreOperation = "read" | "write" | "delete";
|
|
13
|
+
/**
|
|
14
|
+
* @param operation What was being attempted.
|
|
15
|
+
* @param key The entry it was attempted on.
|
|
16
|
+
* @param work The store operation.
|
|
17
|
+
* @returns What the store answered, or `unavailable` when it could not.
|
|
18
|
+
*/
|
|
19
|
+
export declare function attempt<T>(operation: StoreOperation, key: string, work: () => Promise<T>): Promise<Result<T, CacheError>>;
|
|
20
|
+
/**
|
|
21
|
+
* Runs the loader a `fetch` was given.
|
|
22
|
+
*
|
|
23
|
+
* Nothing is logged: the failure is the caller's own and travels in the `Result`,
|
|
24
|
+
* where the original error is reachable as `cause`.
|
|
25
|
+
*
|
|
26
|
+
* @param key The entry the value was being computed for.
|
|
27
|
+
* @param produce Computes the value.
|
|
28
|
+
* @returns The value, or `load_failed` when the loader threw.
|
|
29
|
+
*/
|
|
30
|
+
export declare function attemptLoad<T>(key: string, produce: () => Promise<T>): Promise<Result<T, CacheError>>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs the two kinds of work a cache does that can fail: a store operation, and
|
|
3
|
+
* the loader a `fetch` was given. A store failure is recorded on the invocation's
|
|
4
|
+
* log, because store health is the package's own to report; a loader's is not.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { currentLog } from "@sdxc/logger";
|
|
10
|
+
import { failure, success } from "@sdxc/result";
|
|
11
|
+
import { CacheError } from "../errors.js";
|
|
12
|
+
/**
|
|
13
|
+
* @param operation What was being attempted.
|
|
14
|
+
* @param key The entry it was attempted on.
|
|
15
|
+
* @param work The store operation.
|
|
16
|
+
* @returns What the store answered, or `unavailable` when it could not.
|
|
17
|
+
*/
|
|
18
|
+
export async function attempt(operation, key, work) {
|
|
19
|
+
try {
|
|
20
|
+
return success(await work());
|
|
21
|
+
}
|
|
22
|
+
catch (cause) {
|
|
23
|
+
let reason = cause instanceof Error ? cause.message : String(cause);
|
|
24
|
+
currentLog()?.warn(`cache.${operation}.failed`, { key, reason });
|
|
25
|
+
return failure(new CacheError(`The store could not ${operation} ${key}: ${reason}`, {
|
|
26
|
+
code: "unavailable",
|
|
27
|
+
key,
|
|
28
|
+
cause,
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Runs the loader a `fetch` was given.
|
|
34
|
+
*
|
|
35
|
+
* Nothing is logged: the failure is the caller's own and travels in the `Result`,
|
|
36
|
+
* where the original error is reachable as `cause`.
|
|
37
|
+
*
|
|
38
|
+
* @param key The entry the value was being computed for.
|
|
39
|
+
* @param produce Computes the value.
|
|
40
|
+
* @returns The value, or `load_failed` when the loader threw.
|
|
41
|
+
*/
|
|
42
|
+
export async function attemptLoad(key, produce) {
|
|
43
|
+
try {
|
|
44
|
+
return success(await produce());
|
|
45
|
+
}
|
|
46
|
+
catch (cause) {
|
|
47
|
+
let reason = cause instanceof Error ? cause.message : String(cause);
|
|
48
|
+
return failure(new CacheError(`The value for ${key} could not be computed: ${reason}`, {
|
|
49
|
+
code: "load_failed",
|
|
50
|
+
key,
|
|
51
|
+
cause,
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads and writes the JSON a store holds, answering with a `Result` so a value
|
|
3
|
+
* JSON cannot write and an entry it cannot read are reported rather than thrown.
|
|
4
|
+
* Both halves live here so every adapter serializes a value the same way.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { Result } from "@sdxc/result";
|
|
10
|
+
import type { JSONSerialized } from "@sdxc/types";
|
|
11
|
+
import { CacheError } from "../errors.js";
|
|
12
|
+
/**
|
|
13
|
+
* Writes the value as the text a store holds.
|
|
14
|
+
*
|
|
15
|
+
* @param key The entry being written, which the failure names.
|
|
16
|
+
* @param value What the caller handed over.
|
|
17
|
+
* @returns The text, or `invalid_value` when JSON cannot write it.
|
|
18
|
+
*/
|
|
19
|
+
export declare function serialize(key: string, value: unknown): Result<string, CacheError>;
|
|
20
|
+
/**
|
|
21
|
+
* Reads a stored entry back.
|
|
22
|
+
*
|
|
23
|
+
* @param key The entry being read, which the failure names.
|
|
24
|
+
* @param text What the store held.
|
|
25
|
+
* @returns The value, or `invalid_value` when the entry is not JSON.
|
|
26
|
+
*/
|
|
27
|
+
export declare function parse<T>(key: string, text: string): Result<JSONSerialized<T>, CacheError>;
|
package/dist/lib/json.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads and writes the JSON a store holds, answering with a `Result` so a value
|
|
3
|
+
* JSON cannot write and an entry it cannot read are reported rather than thrown.
|
|
4
|
+
* Both halves live here so every adapter serializes a value the same way.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { failure, success } from "@sdxc/result";
|
|
10
|
+
import { CacheError } from "../errors.js";
|
|
11
|
+
/**
|
|
12
|
+
* Writes the value as the text a store holds.
|
|
13
|
+
*
|
|
14
|
+
* @param key The entry being written, which the failure names.
|
|
15
|
+
* @param value What the caller handed over.
|
|
16
|
+
* @returns The text, or `invalid_value` when JSON cannot write it.
|
|
17
|
+
*/
|
|
18
|
+
export function serialize(key, value) {
|
|
19
|
+
let text;
|
|
20
|
+
try {
|
|
21
|
+
text = JSON.stringify(value);
|
|
22
|
+
}
|
|
23
|
+
catch (cause) {
|
|
24
|
+
return failure(new CacheError(`The value for ${key} cannot be written as JSON.`, {
|
|
25
|
+
code: "invalid_value",
|
|
26
|
+
key,
|
|
27
|
+
cause,
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
if (text === undefined) {
|
|
31
|
+
return failure(new CacheError(`The value for ${key} has no JSON notation.`, {
|
|
32
|
+
code: "invalid_value",
|
|
33
|
+
key,
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
return success(text);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Reads a stored entry back.
|
|
40
|
+
*
|
|
41
|
+
* @param key The entry being read, which the failure names.
|
|
42
|
+
* @param text What the store held.
|
|
43
|
+
* @returns The value, or `invalid_value` when the entry is not JSON.
|
|
44
|
+
*/
|
|
45
|
+
export function parse(key, text) {
|
|
46
|
+
try {
|
|
47
|
+
return success(JSON.parse(text));
|
|
48
|
+
}
|
|
49
|
+
catch (cause) {
|
|
50
|
+
return failure(new CacheError(`The entry stored for ${key} is not JSON.`, {
|
|
51
|
+
code: "invalid_value",
|
|
52
|
+
key,
|
|
53
|
+
cause,
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns the TTL a caller writes into the whole seconds a store counts, so a
|
|
3
|
+
* number and a duration string produce the same expiry and every adapter
|
|
4
|
+
* measures a lifetime the same way.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { DurationInput } from "@sdxc/duration";
|
|
10
|
+
/**
|
|
11
|
+
* A bare number passes through unchanged, so a numeric call site keeps its exact
|
|
12
|
+
* expiry, and a duration string converts, so `3600` and `"1 hour"` agree.
|
|
13
|
+
*
|
|
14
|
+
* @param ttl How long the entry stays readable, or `undefined` for no expiry.
|
|
15
|
+
* @returns Whole seconds, or `undefined` when the entry never expires.
|
|
16
|
+
*/
|
|
17
|
+
export declare function ttlSeconds(ttl: DurationInput | undefined): number | undefined;
|
package/dist/lib/ttl.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns the TTL a caller writes into the whole seconds a store counts, so a
|
|
3
|
+
* number and a duration string produce the same expiry and every adapter
|
|
4
|
+
* measures a lifetime the same way.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { toSeconds } from "@sdxc/duration";
|
|
10
|
+
/**
|
|
11
|
+
* A bare number passes through unchanged, so a numeric call site keeps its exact
|
|
12
|
+
* expiry, and a duration string converts, so `3600` and `"1 hour"` agree.
|
|
13
|
+
*
|
|
14
|
+
* @param ttl How long the entry stays readable, or `undefined` for no expiry.
|
|
15
|
+
* @returns Whole seconds, or `undefined` when the entry never expires.
|
|
16
|
+
*/
|
|
17
|
+
export function ttlSeconds(ttl) {
|
|
18
|
+
if (ttl === undefined)
|
|
19
|
+
return undefined;
|
|
20
|
+
if (typeof ttl === "number")
|
|
21
|
+
return ttl;
|
|
22
|
+
return toSeconds(ttl);
|
|
23
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The suite that says what a cache is, registered as Vitest tests against
|
|
3
|
+
* whatever the caller constructs. Every adapter runs it, which is what makes one
|
|
4
|
+
* a substitute for another rather than something that merely resembles it.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { Cache } from "../index.js";
|
|
10
|
+
/** What the suite needs to exercise an adapter. */
|
|
11
|
+
export interface ConformanceOptions {
|
|
12
|
+
/** Adapter name, which labels the registered suite. */
|
|
13
|
+
name: string;
|
|
14
|
+
/**
|
|
15
|
+
* Builds the cache under test. It is called for every test, so an adapter
|
|
16
|
+
* holding mutable state starts each one clean.
|
|
17
|
+
*/
|
|
18
|
+
create: () => Cache | Promise<Cache>;
|
|
19
|
+
/**
|
|
20
|
+
* Moves the adapter's clock forward, which is what an expiry assertion needs.
|
|
21
|
+
* An adapter that can only expire in real time leaves it out, and the suite
|
|
22
|
+
* registers the expiry tests against the adapters that can.
|
|
23
|
+
*/
|
|
24
|
+
expire?: (seconds: number) => void | Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Registers the suite every cache has to pass.
|
|
28
|
+
*
|
|
29
|
+
* @param options The adapter under test.
|
|
30
|
+
*/
|
|
31
|
+
export declare function conformance({ name, create, expire }: ConformanceOptions): void;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The suite that says what a cache is, registered as Vitest tests against
|
|
3
|
+
* whatever the caller constructs. Every adapter runs it, which is what makes one
|
|
4
|
+
* a substitute for another rather than something that merely resembles it.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { isSuccess, unwrap } from "@sdxc/result";
|
|
10
|
+
import { describe, expect, test, vi } from "vitest";
|
|
11
|
+
/**
|
|
12
|
+
* The shortest TTL the suite asks for. KV refuses anything under 60 seconds, so
|
|
13
|
+
* a suite every adapter can pass cannot assert a shorter lifetime than that.
|
|
14
|
+
*/
|
|
15
|
+
const MIN_TTL_SECONDS = 60;
|
|
16
|
+
/**
|
|
17
|
+
* Registers the suite every cache has to pass.
|
|
18
|
+
*
|
|
19
|
+
* @param options The adapter under test.
|
|
20
|
+
*/
|
|
21
|
+
export function conformance({ name, create, expire }) {
|
|
22
|
+
/** A key no other test has used, so adapters sharing a store cannot collide. */
|
|
23
|
+
let key = () => `conformance:${crypto.randomUUID()}`;
|
|
24
|
+
/** The error a call answered with, asserting that it answered with one. */
|
|
25
|
+
let errorOf = (result) => {
|
|
26
|
+
if (isSuccess(result))
|
|
27
|
+
throw new Error("expected a failure, got a success");
|
|
28
|
+
return result.error;
|
|
29
|
+
};
|
|
30
|
+
describe(`${name} conformance`, () => {
|
|
31
|
+
test("reads back what was written", async () => {
|
|
32
|
+
let cache = await create();
|
|
33
|
+
let k = key();
|
|
34
|
+
unwrap(await cache.write(k, { id: 1, tags: ["news"] }));
|
|
35
|
+
expect(unwrap(await cache.read(k))).toStrictEqual({ id: 1, tags: ["news"] });
|
|
36
|
+
});
|
|
37
|
+
test("succeeds with null for a key it holds nothing for", async () => {
|
|
38
|
+
let cache = await create();
|
|
39
|
+
expect(unwrap(await cache.read(key()))).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
test("is readable as soon as a write resolves", async () => {
|
|
42
|
+
let cache = await create();
|
|
43
|
+
let k = key();
|
|
44
|
+
unwrap(await cache.write(k, "value"));
|
|
45
|
+
expect(unwrap(await cache.read(k))).toBe("value");
|
|
46
|
+
});
|
|
47
|
+
test("replaces the value a key already holds", async () => {
|
|
48
|
+
let cache = await create();
|
|
49
|
+
let k = key();
|
|
50
|
+
unwrap(await cache.write(k, "first"));
|
|
51
|
+
unwrap(await cache.write(k, "second"));
|
|
52
|
+
expect(unwrap(await cache.read(k))).toBe("second");
|
|
53
|
+
});
|
|
54
|
+
test("removes an entry, and removing a missing one succeeds", async () => {
|
|
55
|
+
let cache = await create();
|
|
56
|
+
let k = key();
|
|
57
|
+
unwrap(await cache.write(k, "value"));
|
|
58
|
+
unwrap(await cache.delete(k));
|
|
59
|
+
unwrap(await cache.delete(k));
|
|
60
|
+
expect(unwrap(await cache.read(k))).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
test("returns what it holds without computing", async () => {
|
|
63
|
+
let cache = await create();
|
|
64
|
+
let k = key();
|
|
65
|
+
let load = vi.fn(async () => "computed");
|
|
66
|
+
unwrap(await cache.write(k, "stored"));
|
|
67
|
+
expect(unwrap(await cache.fetch(k, load))).toBe("stored");
|
|
68
|
+
expect(load).not.toHaveBeenCalled();
|
|
69
|
+
});
|
|
70
|
+
test("computes once on a miss and stores what it computed", async () => {
|
|
71
|
+
let cache = await create();
|
|
72
|
+
let k = key();
|
|
73
|
+
let load = vi.fn(async () => "computed");
|
|
74
|
+
expect(unwrap(await cache.fetch(k, load))).toBe("computed");
|
|
75
|
+
expect(unwrap(await cache.fetch(k, load))).toBe("computed");
|
|
76
|
+
expect(load).toHaveBeenCalledTimes(1);
|
|
77
|
+
});
|
|
78
|
+
test("answers a hit and a miss with the same type", async () => {
|
|
79
|
+
let cache = await create();
|
|
80
|
+
let k = key();
|
|
81
|
+
let load = async () => ({ at: new Date("2026-09-07T00:00:00.000Z") });
|
|
82
|
+
let miss = unwrap(await cache.fetch(k, load));
|
|
83
|
+
let hit = unwrap(await cache.fetch(k, load));
|
|
84
|
+
expect(miss).toStrictEqual({ at: "2026-09-07T00:00:00.000Z" });
|
|
85
|
+
expect(hit).toStrictEqual(miss);
|
|
86
|
+
});
|
|
87
|
+
test("counts a stored null as a hit", async () => {
|
|
88
|
+
let cache = await create();
|
|
89
|
+
let k = key();
|
|
90
|
+
let load = vi.fn(async () => null);
|
|
91
|
+
expect(unwrap(await cache.fetch(k, load))).toBeNull();
|
|
92
|
+
expect(unwrap(await cache.fetch(k, load))).toBeNull();
|
|
93
|
+
expect(load).toHaveBeenCalledTimes(1);
|
|
94
|
+
});
|
|
95
|
+
test("reports a failed loader as load_failed, carrying what it threw", async () => {
|
|
96
|
+
let cache = await create();
|
|
97
|
+
let thrown = new Error("loader failed");
|
|
98
|
+
let error = errorOf(await cache.fetch(key(), async () => {
|
|
99
|
+
throw thrown;
|
|
100
|
+
}));
|
|
101
|
+
expect(error.code).toBe("load_failed");
|
|
102
|
+
expect(error.cause).toBe(thrown);
|
|
103
|
+
});
|
|
104
|
+
test("reports a value JSON cannot write as invalid_value", async () => {
|
|
105
|
+
let cache = await create();
|
|
106
|
+
let cyclic = [];
|
|
107
|
+
cyclic.push(cyclic);
|
|
108
|
+
expect(errorOf(await cache.write(key(), cyclic)).code).toBe("invalid_value");
|
|
109
|
+
expect(errorOf(await cache.fetch(key(), async () => cyclic)).code).toBe("invalid_value");
|
|
110
|
+
});
|
|
111
|
+
test("names the key on every failure it reports", async () => {
|
|
112
|
+
let cache = await create();
|
|
113
|
+
let k = key();
|
|
114
|
+
let error = errorOf(await cache.fetch(k, async () => {
|
|
115
|
+
throw new Error("loader failed");
|
|
116
|
+
}));
|
|
117
|
+
expect(error.key).toBe(k);
|
|
118
|
+
});
|
|
119
|
+
test("keeps an entry written without a ttl", async () => {
|
|
120
|
+
let cache = await create();
|
|
121
|
+
let k = key();
|
|
122
|
+
unwrap(await cache.write(k, "value"));
|
|
123
|
+
expect(unwrap(await cache.read(k))).toBe("value");
|
|
124
|
+
});
|
|
125
|
+
if (expire === undefined)
|
|
126
|
+
return;
|
|
127
|
+
test("hides an entry once its ttl has passed", async () => {
|
|
128
|
+
let cache = await create();
|
|
129
|
+
let k = key();
|
|
130
|
+
unwrap(await cache.write(k, "value", { ttl: MIN_TTL_SECONDS }));
|
|
131
|
+
await expire(MIN_TTL_SECONDS);
|
|
132
|
+
expect(unwrap(await cache.read(k))).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
test("keeps an entry until its ttl has passed", async () => {
|
|
135
|
+
let cache = await create();
|
|
136
|
+
let k = key();
|
|
137
|
+
unwrap(await cache.write(k, "value", { ttl: MIN_TTL_SECONDS }));
|
|
138
|
+
await expire(MIN_TTL_SECONDS - 1);
|
|
139
|
+
expect(unwrap(await cache.read(k))).toBe("value");
|
|
140
|
+
});
|
|
141
|
+
test("counts a duration string and its seconds as the same lifetime", async () => {
|
|
142
|
+
let cache = await create();
|
|
143
|
+
let seconds = key();
|
|
144
|
+
let duration = key();
|
|
145
|
+
unwrap(await cache.write(seconds, "value", { ttl: 60 }));
|
|
146
|
+
unwrap(await cache.write(duration, "value", { ttl: "1 minute" }));
|
|
147
|
+
await expire(MIN_TTL_SECONDS);
|
|
148
|
+
expect(unwrap(await cache.read(seconds))).toBeNull();
|
|
149
|
+
expect(unwrap(await cache.read(duration))).toBeNull();
|
|
150
|
+
});
|
|
151
|
+
test("recomputes through fetch once the entry has expired", async () => {
|
|
152
|
+
let cache = await create();
|
|
153
|
+
let k = key();
|
|
154
|
+
let load = vi.fn(async () => "computed");
|
|
155
|
+
unwrap(await cache.fetch(k, load, { ttl: MIN_TTL_SECONDS }));
|
|
156
|
+
await expire(MIN_TTL_SECONDS);
|
|
157
|
+
unwrap(await cache.fetch(k, load, { ttl: MIN_TTL_SECONDS }));
|
|
158
|
+
expect(load).toHaveBeenCalledTimes(2);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sdxc/cache",
|
|
3
|
+
"version": "0.0.0-pre.1",
|
|
4
|
+
"description": "Cache contract with adapters for memory and Cloudflare KV",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./dist/index.js",
|
|
9
|
+
"./conformance": "./dist/testing/conformance.js",
|
|
10
|
+
"./memory": "./dist/adapters/memory.js",
|
|
11
|
+
"./worker-kv": "./dist/adapters/worker-kv.js"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@sdxc/duration": "2026.9.11",
|
|
15
|
+
"@sdxc/logger": "2026.9.11",
|
|
16
|
+
"@sdxc/result": "2026.9.11",
|
|
17
|
+
"@sdxc/types": "2026.9.11"
|
|
18
|
+
},
|
|
19
|
+
"gitHead": "e96a064f9208eb667923f57374e2cdaac73c72fb",
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/sergiodxa/monorepo.git",
|
|
26
|
+
"directory": "packages/cache"
|
|
27
|
+
}
|
|
28
|
+
}
|