@zerotal/cache 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +82 -0
- package/package.json +54 -0
- package/src/CacheManager.ts +190 -0
- package/src/IdempotencyMiddleware.ts +261 -0
- package/src/commands/CacheClearCommand.ts +29 -0
- package/src/commands/index.ts +1 -0
- package/src/config.ts +52 -0
- package/src/drivers/CacheDriver.ts +34 -0
- package/src/drivers/MemoryDriver.ts +89 -0
- package/src/drivers/RedisDriver.ts +55 -0
- package/src/drivers/SqliteDriver.ts +99 -0
- package/src/errors.ts +32 -0
- package/src/events.ts +32 -0
- package/src/facades/Cache.ts +3 -0
- package/src/global.d.ts +30 -0
- package/src/index.ts +23 -0
- package/src/observability.ts +79 -0
- package/src/provider/CacheProvider.ts +83 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog — @zerotal/cache
|
|
2
|
+
|
|
3
|
+
All notable changes to this package are documented here. The format is
|
|
4
|
+
based on [Keep a Changelog](https://keepachangelog.com/); this package
|
|
5
|
+
follows the Zerotal monorepo's unified versioning.
|
|
6
|
+
|
|
7
|
+
**Maturity: `stable`**
|
|
8
|
+
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
## [1.0.0] — 2026-08-05
|
|
12
|
+
|
|
13
|
+
_First public release._
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Added a typed error vocabulary (`CacheError`/`CacheSerializationError`/`CacheDeserializationError`, `E_CACHE_*` codes).
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zerotal
|
|
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,82 @@
|
|
|
1
|
+
# @zerotal/cache
|
|
2
|
+
|
|
3
|
+
> A unified caching layer with in-memory, SQLite, and Redis drivers.
|
|
4
|
+
|
|
5
|
+
Cache database queries, computed values, and external API responses behind a single `Cache` facade. Tag related entries to invalidate them together, coalesce concurrent misses with `remember()`, and protect mutating endpoints with idempotency middleware.
|
|
6
|
+
|
|
7
|
+
Part of the [Zerotal](../../README.md) framework. Requires **Bun ≥ 1.3.14**.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bun add @zerotal/cache
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Setup
|
|
16
|
+
|
|
17
|
+
Register the provider in `bootstrap/providers.ts`:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { CacheProvider } from "@zerotal/cache";
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
`remember()` is the primary workhorse — on a miss it calls the factory, stores the result, and returns it. Concurrent callers for the same key coalesce, so the factory runs exactly once:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { Cache } from "@zerotal/cache";
|
|
29
|
+
|
|
30
|
+
const posts = await Cache.remember("posts:page:1", 300, async () => {
|
|
31
|
+
return Post.query().where("status", "published").orderBy("published_at", "desc").paginate(10, 1);
|
|
32
|
+
});
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Basic reads, writes, and atomic counters:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
await Cache.set("user:1", { id: 1, name: "Alice" }, 300); // TTL in seconds
|
|
39
|
+
const user = await Cache.get<{ id: number; name: string }>("user:1"); // null on miss
|
|
40
|
+
await Cache.forget("user:1");
|
|
41
|
+
|
|
42
|
+
await Cache.increment("page-views:posts:42");
|
|
43
|
+
const token = await Cache.pull<string>(`otp:${userId}`); // get + delete atomically
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Group keys under tags so you can invalidate them together:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
await Cache.tags(["posts", "public"]).set("posts:page:1", data, 300);
|
|
50
|
+
await Cache.tags(["posts"]).flush(); // busts every "posts"-tagged key
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Protect mutating routes against double-execution via the `Idempotency-Key` header:
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import { IdempotencyMiddleware, Cache } from "@zerotal/cache";
|
|
57
|
+
|
|
58
|
+
Router.post("/api/orders", [OrderController, "store"], {
|
|
59
|
+
middleware: [IdempotencyMiddleware.with({ cache: Cache.instance() })],
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Exports
|
|
64
|
+
|
|
65
|
+
| Export | Description |
|
|
66
|
+
| ------------------------------------------------------------ | ------------------------------------------------------------- |
|
|
67
|
+
| `Cache` | Facade over the configured cache driver. |
|
|
68
|
+
| `CacheManager`, `TaggedCache` | The underlying manager and tag-scoped view. |
|
|
69
|
+
| `CacheProvider` | Service provider — register in `bootstrap/providers.ts`. |
|
|
70
|
+
| `CacheConfig`, `CacheConfigShape` | Config factory and its type. |
|
|
71
|
+
| `IdempotencyMiddleware`, `IdempotencyOptions` | Replay-safe middleware for mutating requests. |
|
|
72
|
+
| `CacheDriver`, `MemoryDriver`, `SqliteDriver`, `RedisDriver` | Driver contract and built-in drivers for custom registration. |
|
|
73
|
+
| `errors` | Typed error vocabulary (re-exported). |
|
|
74
|
+
|
|
75
|
+
Subpath exports:
|
|
76
|
+
|
|
77
|
+
- `@zerotal/cache` — the public API above.
|
|
78
|
+
- `@zerotal/cache/commands` — `CacheClearCommand` for the CLI.
|
|
79
|
+
|
|
80
|
+
## Documentation
|
|
81
|
+
|
|
82
|
+
- [Cache](../../docs/cache.md)
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zerotal/cache",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"maturity": "stable",
|
|
6
|
+
"private": false,
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./src/index.ts",
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.ts",
|
|
12
|
+
"./commands": "./src/commands/index.ts"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"CHANGELOG.md",
|
|
16
|
+
"src",
|
|
17
|
+
"!src/**/*.test.ts",
|
|
18
|
+
"!src/**/*.test.tsx",
|
|
19
|
+
"!src/**/*.spec.ts",
|
|
20
|
+
"!src/**/__fixtures__/**"
|
|
21
|
+
],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"bun": ">=1.3.14"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "bun test",
|
|
30
|
+
"typecheck": "tsc --noEmit"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@zerotal/core": "1.0.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"typescript": "^5.8.0"
|
|
37
|
+
},
|
|
38
|
+
"description": "Cache layer for Zerotal with SQLite, Redis, and in-memory drivers, tags, and stampede protection.",
|
|
39
|
+
"keywords": [
|
|
40
|
+
"zerotal",
|
|
41
|
+
"bun",
|
|
42
|
+
"typescript",
|
|
43
|
+
"framework",
|
|
44
|
+
"cache",
|
|
45
|
+
"redis"
|
|
46
|
+
],
|
|
47
|
+
"repository": {
|
|
48
|
+
"type": "git",
|
|
49
|
+
"url": "git+https://github.com/zerotaldev/zerotal.git",
|
|
50
|
+
"directory": "packages/cache"
|
|
51
|
+
},
|
|
52
|
+
"homepage": "https://github.com/zerotaldev/zerotal/tree/main/packages/cache#readme",
|
|
53
|
+
"bugs": "https://github.com/zerotaldev/zerotal/issues"
|
|
54
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import type { CacheDriver } from "./drivers/CacheDriver.ts";
|
|
2
|
+
import { CacheSerializationError, CacheDeserializationError } from "./errors.ts";
|
|
3
|
+
import { FrameworkEvents, sha256Hex } from "@zerotal/core";
|
|
4
|
+
import { CacheQueried } from "./events.ts";
|
|
5
|
+
import type { LockManager } from "@zerotal/core/lock";
|
|
6
|
+
import { LockNotAcquiredError } from "@zerotal/core/lock";
|
|
7
|
+
|
|
8
|
+
// Cross-process recompute lock for remember(): how long the lock is held while the
|
|
9
|
+
// factory runs, and how long a waiter blocks for the holder before recomputing anyway.
|
|
10
|
+
const STAMPEDE_LOCK_TTL = 30; // seconds
|
|
11
|
+
const STAMPEDE_LOCK_WAIT = 30; // seconds
|
|
12
|
+
|
|
13
|
+
// ── CacheManager ──────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export class CacheManager {
|
|
16
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
17
|
+
private _inFlight = new Map<string, Promise<any>>();
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
private _driver: CacheDriver,
|
|
21
|
+
private _prefix: string = "",
|
|
22
|
+
private _defaultTtl?: number,
|
|
23
|
+
private _lock: LockManager | null = null,
|
|
24
|
+
private _stampedeProtection: boolean = true,
|
|
25
|
+
) {}
|
|
26
|
+
|
|
27
|
+
private _key(key: string): string {
|
|
28
|
+
return this._prefix + key;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** JSON-encode a value for storage, surfacing a typed CacheSerializationError. */
|
|
32
|
+
private _encode(key: string, value: unknown): string {
|
|
33
|
+
try {
|
|
34
|
+
return JSON.stringify(value);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
throw new CacheSerializationError(key, err);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** JSON-decode a cached value, surfacing a typed CacheDeserializationError. */
|
|
41
|
+
private _decode<T>(key: string, raw: string): T {
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(raw) as T;
|
|
44
|
+
} catch (err) {
|
|
45
|
+
throw new CacheDeserializationError(key, err);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async get<T>(key: string): Promise<T | null> {
|
|
50
|
+
const t0 = Date.now();
|
|
51
|
+
const raw = await this._driver.get(this._key(key));
|
|
52
|
+
FrameworkEvents.emit(
|
|
53
|
+
new CacheQueried(raw === null ? "miss" : "hit", key, undefined, Date.now() - t0),
|
|
54
|
+
);
|
|
55
|
+
if (raw === null) return null;
|
|
56
|
+
return this._decode<T>(key, raw);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async set(key: string, value: unknown, ttl?: number): Promise<void> {
|
|
60
|
+
const t0 = Date.now();
|
|
61
|
+
const effectiveTtl = ttl ?? this._defaultTtl;
|
|
62
|
+
await this._driver.set(this._key(key), this._encode(key, value), effectiveTtl);
|
|
63
|
+
FrameworkEvents.emit(new CacheQueried("write", key, effectiveTtl, Date.now() - t0));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async forget(key: string): Promise<void> {
|
|
67
|
+
const t0 = Date.now();
|
|
68
|
+
await this._driver.forget(this._key(key));
|
|
69
|
+
FrameworkEvents.emit(new CacheQueried("forget", key, undefined, Date.now() - t0));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async has(key: string): Promise<boolean> {
|
|
73
|
+
const t0 = Date.now();
|
|
74
|
+
const result = await this._driver.has(this._key(key));
|
|
75
|
+
FrameworkEvents.emit(new CacheQueried("has", key, undefined, Date.now() - t0));
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async flush(): Promise<void> {
|
|
80
|
+
const t0 = Date.now();
|
|
81
|
+
await this._driver.flush(this._prefix);
|
|
82
|
+
FrameworkEvents.emit(new CacheQueried("flush", "*", undefined, Date.now() - t0));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async remember<T>(key: string, ttl: number, fn: () => Promise<T> | T): Promise<T> {
|
|
86
|
+
const cached = await this.get<T>(key);
|
|
87
|
+
if (cached !== null) return cached;
|
|
88
|
+
|
|
89
|
+
const prefixedKey = this._key(key);
|
|
90
|
+
|
|
91
|
+
// Coalesce concurrent misses: await existing in-flight factory
|
|
92
|
+
const existing = this._inFlight.get(prefixedKey);
|
|
93
|
+
if (existing !== undefined) return existing as Promise<T>;
|
|
94
|
+
|
|
95
|
+
const promise = (async () => {
|
|
96
|
+
try {
|
|
97
|
+
return await this._computeAndStore(key, prefixedKey, ttl, fn);
|
|
98
|
+
} finally {
|
|
99
|
+
this._inFlight.delete(prefixedKey);
|
|
100
|
+
}
|
|
101
|
+
})();
|
|
102
|
+
|
|
103
|
+
this._inFlight.set(prefixedKey, promise);
|
|
104
|
+
return promise;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Run the factory and store its value. When stampede protection is on and a lock
|
|
109
|
+
* driver is configured, a cross-process lock ensures only one node recomputes a
|
|
110
|
+
* cold key; waiters re-check the cache after the holder finishes. A waiter that
|
|
111
|
+
* can't get the lock in time recomputes rather than failing — caching stays
|
|
112
|
+
* best-effort. Without a lock driver, only the in-process coalescing above applies.
|
|
113
|
+
*/
|
|
114
|
+
private async _computeAndStore<T>(
|
|
115
|
+
key: string,
|
|
116
|
+
prefixedKey: string,
|
|
117
|
+
ttl: number,
|
|
118
|
+
fn: () => Promise<T> | T,
|
|
119
|
+
): Promise<T> {
|
|
120
|
+
const compute = async (): Promise<T> => {
|
|
121
|
+
const value = await fn();
|
|
122
|
+
await this.set(key, value, ttl);
|
|
123
|
+
return value;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
if (!this._stampedeProtection || !this._lock) return compute();
|
|
127
|
+
|
|
128
|
+
const lockKey = `cache:stampede:${prefixedKey}`;
|
|
129
|
+
const recheckThenCompute = async (): Promise<T> => {
|
|
130
|
+
// Another holder may have populated the key while we waited for the lock.
|
|
131
|
+
const fresh = await this.get<T>(key);
|
|
132
|
+
if (fresh !== null) return fresh;
|
|
133
|
+
return compute();
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
return await this._lock.block(lockKey, STAMPEDE_LOCK_TTL, recheckThenCompute, {
|
|
138
|
+
timeout: STAMPEDE_LOCK_WAIT,
|
|
139
|
+
retryDelay: 50,
|
|
140
|
+
});
|
|
141
|
+
} catch (err) {
|
|
142
|
+
if (err instanceof LockNotAcquiredError) return recheckThenCompute();
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async forever(key: string, value: unknown): Promise<void> {
|
|
148
|
+
const t0 = Date.now();
|
|
149
|
+
await this._driver.set(this._key(key), this._encode(key, value), undefined);
|
|
150
|
+
FrameworkEvents.emit(new CacheQueried("write", key, undefined, Date.now() - t0));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Scope reads/writes to a **tag namespace**. Tags are a deterministic, ordered
|
|
155
|
+
* namespace: `tags([...])` sorts and joins the names into a key prefix, so
|
|
156
|
+
* `tags(['a','b'])` and `tags(['b','a'])` address the same entries (the order
|
|
157
|
+
* no longer matters), and `tags([...]).flush()` clears exactly that namespace.
|
|
158
|
+
*
|
|
159
|
+
* Note: this is namespacing, not a per-tag index — flushing `tags(['b'])` does
|
|
160
|
+
* NOT invalidate entries written under `tags(['a','b'])`. Flush with the same
|
|
161
|
+
* tag set you wrote with. (A true multi-tag index — where any one tag can
|
|
162
|
+
* invalidate every entry carrying it — is intentionally out of scope for v1.)
|
|
163
|
+
*
|
|
164
|
+
* The namespace is a fixed-length digest of the sorted tag list rather than the names
|
|
165
|
+
* joined together, because `flush()` is a prefix delete and a joined prefix contains its
|
|
166
|
+
* own sub-namespaces: `tags(['a'])` produced `a:`, which matches `a:b:key`, so flushing
|
|
167
|
+
* `['a']` wiped the `['a','b']` namespace this docblock promises it will not. A digest
|
|
168
|
+
* also stops a tag that happens to contain the separator from colliding —
|
|
169
|
+
* `tags(['a:b'])` and `tags(['a','b'])` are different namespaces.
|
|
170
|
+
*/
|
|
171
|
+
tags(tagNames: string[]): TaggedCache {
|
|
172
|
+
// Sorted so the namespace is order-independent: tags(['b','a']) === tags(['a','b']).
|
|
173
|
+
// NUL-joined so the digest input is unambiguous for any tag content.
|
|
174
|
+
const canonical = [...tagNames].sort().join(String.fromCharCode(0));
|
|
175
|
+
const tagPrefix = `t${sha256Hex(canonical).slice(0, 16)}:`;
|
|
176
|
+
return new TaggedCache(
|
|
177
|
+
this._driver,
|
|
178
|
+
this._prefix + tagPrefix,
|
|
179
|
+
undefined,
|
|
180
|
+
this._lock,
|
|
181
|
+
this._stampedeProtection,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
dispose(): void {
|
|
186
|
+
this._driver.dispose?.();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export class TaggedCache extends CacheManager {}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import type { NextFn, HttpContext } from "@zerotal/core";
|
|
2
|
+
import { currentApp, BaseMiddleware } from "@zerotal/core";
|
|
3
|
+
import type { LockManager } from "@zerotal/core/lock";
|
|
4
|
+
import { LockNotAcquiredError } from "@zerotal/core/lock";
|
|
5
|
+
import type { CacheManager } from "./CacheManager.ts";
|
|
6
|
+
|
|
7
|
+
// Cross-process idempotency lock: hold time while the handler runs, and how long a
|
|
8
|
+
// concurrent request waits for the holder before executing anyway.
|
|
9
|
+
const IDEMPOTENCY_LOCK_TTL = 60; // seconds
|
|
10
|
+
const IDEMPOTENCY_LOCK_WAIT = 30; // seconds
|
|
11
|
+
|
|
12
|
+
// ── Stored payload ────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
interface StoredResponse {
|
|
15
|
+
status: number;
|
|
16
|
+
headers: Record<string, string>;
|
|
17
|
+
body: string;
|
|
18
|
+
bodyHash: string | null; // SHA-256 of original request body, null when validateBody disabled
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ── Options ───────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export interface IdempotencyOptions {
|
|
24
|
+
/**
|
|
25
|
+
* CacheManager that stores idempotent responses.
|
|
26
|
+
* Required — pass via `IdempotencyMiddleware.with({ cache: manager })`.
|
|
27
|
+
*/
|
|
28
|
+
cache: CacheManager | undefined;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Seconds to retain a stored response. After this, the same key can be
|
|
32
|
+
* reused without replaying the previous response. Default: `86400` (24 h).
|
|
33
|
+
*/
|
|
34
|
+
ttl: number;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* HTTP methods subject to idempotency checks.
|
|
38
|
+
* Default: `['POST', 'PUT', 'PATCH', 'DELETE']`.
|
|
39
|
+
*/
|
|
40
|
+
methods: string[];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Request header that carries the client-supplied idempotency key.
|
|
44
|
+
* Default: `'Idempotency-Key'`.
|
|
45
|
+
*/
|
|
46
|
+
header: string;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* When `true`, a 422 is returned if the same idempotency key arrives with
|
|
50
|
+
* a different request body. Useful for detecting accidental key reuse.
|
|
51
|
+
* Default: `false`.
|
|
52
|
+
*/
|
|
53
|
+
validateBody: boolean;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Take a cross-process lock (via the lock primitive) so only one node executes the
|
|
57
|
+
* handler for a given key at a time, even across machines. Default: `true`.
|
|
58
|
+
* Degrades to in-process coalescing + cache replay when no lock driver is configured.
|
|
59
|
+
*/
|
|
60
|
+
useLock: boolean;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Explicit lock manager. When omitted (and `useLock` is true), the manager is
|
|
64
|
+
* resolved from the container's `lock` binding at request time.
|
|
65
|
+
*/
|
|
66
|
+
lock?: LockManager;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Middleware ────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Idempotency middleware — prevents double-execution of mutating requests.
|
|
73
|
+
*
|
|
74
|
+
* Clients attach an `Idempotency-Key: <uuid>` header to any mutating request.
|
|
75
|
+
* On the first request the handler executes normally and the response is
|
|
76
|
+
* stored in the cache. All subsequent requests with the same key receive the
|
|
77
|
+
* stored response without re-running the handler.
|
|
78
|
+
*
|
|
79
|
+
* Concurrent requests for the same key are coalesced: within a process via an
|
|
80
|
+
* in-flight map, and across processes via a cross-process lock (the lock
|
|
81
|
+
* primitive) so only one node executes the handler at a time. Cross-process
|
|
82
|
+
* locking is on by default and degrades to cache-backed replay when no lock
|
|
83
|
+
* driver is configured; disable it with `useLock: false`.
|
|
84
|
+
*
|
|
85
|
+
* Replayed responses carry an `Idempotency-Replay: true` header so clients
|
|
86
|
+
* can distinguish fresh from cached responses.
|
|
87
|
+
*
|
|
88
|
+
* 5xx responses are never cached so transient errors don't permanently
|
|
89
|
+
* suppress retries.
|
|
90
|
+
*
|
|
91
|
+
* @example
|
|
92
|
+
* import { IdempotencyMiddleware } from '@zerotal/cache';
|
|
93
|
+
* import { Cache } from '@zerotal/cache';
|
|
94
|
+
*
|
|
95
|
+
* // Per-route
|
|
96
|
+
* Router.post('/api/orders', [OrderController, 'store'], {
|
|
97
|
+
* middleware: [IdempotencyMiddleware.with({ cache: Cache.instance() })],
|
|
98
|
+
* });
|
|
99
|
+
*
|
|
100
|
+
* // Global
|
|
101
|
+
* app.use([IdempotencyMiddleware.with({ cache: cacheManager, ttl: 48 * 3600 })]);
|
|
102
|
+
*/
|
|
103
|
+
export class IdempotencyMiddleware extends BaseMiddleware<IdempotencyOptions> {
|
|
104
|
+
protected options: IdempotencyOptions = {
|
|
105
|
+
cache: undefined,
|
|
106
|
+
ttl: 86_400, // 24 h
|
|
107
|
+
methods: ["POST", "PUT", "PATCH", "DELETE"],
|
|
108
|
+
header: "Idempotency-Key",
|
|
109
|
+
validateBody: false,
|
|
110
|
+
useLock: true,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Per-process in-flight map — coalesces concurrent requests with the same key
|
|
114
|
+
// so only one handler execution runs at a time within this process.
|
|
115
|
+
private static readonly _inFlight = new Map<string, Promise<Response>>();
|
|
116
|
+
|
|
117
|
+
async handle(http: HttpContext, next: NextFn): Promise<Response | void> {
|
|
118
|
+
const method = http.request.method.toUpperCase();
|
|
119
|
+
if (!this.options.methods.includes(method)) return next();
|
|
120
|
+
|
|
121
|
+
const idempotencyKey = http.header(this.options.header);
|
|
122
|
+
if (!idempotencyKey) return next();
|
|
123
|
+
|
|
124
|
+
const { cache, ttl, validateBody } = this.options;
|
|
125
|
+
if (!cache) return next();
|
|
126
|
+
|
|
127
|
+
const cacheKey = `idempotency:${idempotencyKey.trim().slice(0, 512)}`;
|
|
128
|
+
|
|
129
|
+
// Compute body hash now — before next() can consume the stream.
|
|
130
|
+
const bodyHash = validateBody ? await _hashBody(http.request) : null;
|
|
131
|
+
|
|
132
|
+
// ── Replay from cache ─────────────────────────────────────────────────────
|
|
133
|
+
const stored = await cache.get<StoredResponse>(cacheKey);
|
|
134
|
+
if (stored) {
|
|
135
|
+
if (validateBody && stored.bodyHash !== null && stored.bodyHash !== bodyHash) {
|
|
136
|
+
return Response.json(
|
|
137
|
+
{
|
|
138
|
+
message: "This Idempotency-Key was used with a different request body.",
|
|
139
|
+
},
|
|
140
|
+
{ status: 422 },
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return _rehydrate(stored, true);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Coalesce within process ───────────────────────────────────────────────
|
|
147
|
+
const inFlight = IdempotencyMiddleware._inFlight.get(cacheKey);
|
|
148
|
+
if (inFlight) {
|
|
149
|
+
const replayed = (await inFlight).clone();
|
|
150
|
+
replayed.headers.set("Idempotency-Replay", "true");
|
|
151
|
+
return replayed;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Run the handler exactly once and cache its response. Re-checks the cache first
|
|
155
|
+
// so a request that just waited on the cross-process lock replays the stored
|
|
156
|
+
// response instead of executing the handler again.
|
|
157
|
+
const executeOnce = async (): Promise<Response> => {
|
|
158
|
+
const fresh = await cache.get<StoredResponse>(cacheKey);
|
|
159
|
+
if (fresh) {
|
|
160
|
+
if (validateBody && fresh.bodyHash !== null && fresh.bodyHash !== bodyHash) {
|
|
161
|
+
const conflict = Response.json(
|
|
162
|
+
{ message: "This Idempotency-Key was used with a different request body." },
|
|
163
|
+
{ status: 422 },
|
|
164
|
+
);
|
|
165
|
+
http.response = conflict;
|
|
166
|
+
return conflict;
|
|
167
|
+
}
|
|
168
|
+
const replay = _rehydrate(fresh, true);
|
|
169
|
+
http.response = replay;
|
|
170
|
+
return replay;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
await next();
|
|
174
|
+
|
|
175
|
+
if (http.response && http.response.status < 500) {
|
|
176
|
+
const serialized = await _serialize(http.response, bodyHash);
|
|
177
|
+
// Replace http.response with a fresh response so the body is reusable
|
|
178
|
+
// by anything downstream that still needs to read it.
|
|
179
|
+
http.response = _rehydrate(serialized, false);
|
|
180
|
+
await cache.set(cacheKey, serialized, ttl);
|
|
181
|
+
return http.response;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const fallback = http.response ?? new Response("", { status: 500 });
|
|
185
|
+
http.response = fallback;
|
|
186
|
+
return fallback;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// ── Execute under a cross-process lock, then resolve in-process waiters ────
|
|
190
|
+
const lock = this._resolveLock();
|
|
191
|
+
const runGuarded = async (): Promise<Response> => {
|
|
192
|
+
if (!lock) return executeOnce();
|
|
193
|
+
try {
|
|
194
|
+
return await lock.block(`idempotency:lock:${cacheKey}`, IDEMPOTENCY_LOCK_TTL, executeOnce, {
|
|
195
|
+
timeout: IDEMPOTENCY_LOCK_WAIT,
|
|
196
|
+
retryDelay: 50,
|
|
197
|
+
});
|
|
198
|
+
} catch (err) {
|
|
199
|
+
// Couldn't get the lock in time — run anyway rather than fail the request.
|
|
200
|
+
if (err instanceof LockNotAcquiredError) return executeOnce();
|
|
201
|
+
throw err;
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
let resolveInFlight!: (r: Response) => void;
|
|
206
|
+
const inFlightPromise = new Promise<Response>((resolve) => {
|
|
207
|
+
resolveInFlight = resolve;
|
|
208
|
+
});
|
|
209
|
+
IdempotencyMiddleware._inFlight.set(cacheKey, inFlightPromise);
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
const result = await runGuarded();
|
|
213
|
+
resolveInFlight(result.clone());
|
|
214
|
+
return result;
|
|
215
|
+
} catch (err) {
|
|
216
|
+
resolveInFlight(new Response("", { status: 500 }));
|
|
217
|
+
throw err;
|
|
218
|
+
} finally {
|
|
219
|
+
IdempotencyMiddleware._inFlight.delete(cacheKey);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Resolve the lock manager: explicit option, else the container's `lock` binding. */
|
|
224
|
+
private _resolveLock(): LockManager | null {
|
|
225
|
+
if (this.options.lock) return this.options.lock;
|
|
226
|
+
if (!this.options.useLock) return null;
|
|
227
|
+
try {
|
|
228
|
+
return (currentApp().container.tryMake("lock") as LockManager | null) ?? null;
|
|
229
|
+
} catch {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** @internal — expose for testing only */
|
|
235
|
+
static _clearInFlight(): void {
|
|
236
|
+
IdempotencyMiddleware._inFlight.clear();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── Serialization helpers ─────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
async function _serialize(res: Response, bodyHash: string | null): Promise<StoredResponse> {
|
|
243
|
+
const headers: Record<string, string> = {};
|
|
244
|
+
res.headers.forEach((value, key) => {
|
|
245
|
+
headers[key] = value;
|
|
246
|
+
});
|
|
247
|
+
const body = await res.clone().text();
|
|
248
|
+
return { status: res.status, headers, body, bodyHash };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function _rehydrate(stored: StoredResponse, replay: boolean): Response {
|
|
252
|
+
const headers = { ...stored.headers };
|
|
253
|
+
if (replay) headers["Idempotency-Replay"] = "true";
|
|
254
|
+
return new Response(stored.body, { status: stored.status, headers });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function _hashBody(req: Request): Promise<string> {
|
|
258
|
+
const buf = await req.clone().arrayBuffer();
|
|
259
|
+
const hash = await crypto.subtle.digest("SHA-256", buf);
|
|
260
|
+
return Buffer.from(hash).toString("hex");
|
|
261
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Application } from "@zerotal/core";
|
|
2
|
+
import { Command } from "@zerotal/core";
|
|
3
|
+
import type { CacheManager } from "../CacheManager.ts";
|
|
4
|
+
|
|
5
|
+
export class CacheClearCommand extends Command {
|
|
6
|
+
static commandName = "cache:clear";
|
|
7
|
+
static description = "Clear all cached values";
|
|
8
|
+
static needsApp = true;
|
|
9
|
+
|
|
10
|
+
static flags = [
|
|
11
|
+
{
|
|
12
|
+
name: "driver",
|
|
13
|
+
type: "string" as const,
|
|
14
|
+
description: "Clear a specific driver (sqlite | redis | memory)",
|
|
15
|
+
},
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
async run(): Promise<void> {
|
|
19
|
+
const app = this.app as Application | undefined;
|
|
20
|
+
if (!app) {
|
|
21
|
+
this.error("Application not available.");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const cache = app.container.makeSync("cache") as CacheManager;
|
|
26
|
+
await cache.flush();
|
|
27
|
+
this.info("Cache cleared.");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { CacheClearCommand } from "./CacheClearCommand.ts";
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { deepMerge } from "@zerotal/core";
|
|
2
|
+
|
|
3
|
+
export interface CacheConfigShape {
|
|
4
|
+
/** Which cache driver to use. Default: 'sqlite' */
|
|
5
|
+
driver: "sqlite" | "redis" | "memory";
|
|
6
|
+
/**
|
|
7
|
+
* Key prefix prepended to all cache keys. Default: `'zerotal:cache:'`.
|
|
8
|
+
* This MUST stay distinct from the queue driver's `zerotal:jobs:` namespace —
|
|
9
|
+
* a shared `zerotal:` prefix meant `Cache.flush()` (KEYS zerotal:* + DEL) also
|
|
10
|
+
* deleted every pending/failed job on the same Redis DB.
|
|
11
|
+
*/
|
|
12
|
+
prefix: string;
|
|
13
|
+
/** Default TTL in seconds. Default: 3600 (1 hour) */
|
|
14
|
+
ttl: number;
|
|
15
|
+
/**
|
|
16
|
+
* Guard `remember()` recomputes with a cross-process lock so only one node runs the
|
|
17
|
+
* factory on a cold key (cache stampede protection). Requires a lock driver to be
|
|
18
|
+
* configured; degrades to in-process coalescing otherwise. Default: `true`.
|
|
19
|
+
*/
|
|
20
|
+
stampedeProtection: boolean;
|
|
21
|
+
/** SQLite-specific options */
|
|
22
|
+
sqlite: {
|
|
23
|
+
/** Path to the SQLite file. Use ':memory:' for in-process cache. */
|
|
24
|
+
path: string;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const defaults: CacheConfigShape = {
|
|
29
|
+
driver: "sqlite",
|
|
30
|
+
prefix: "zerotal:cache:",
|
|
31
|
+
ttl: 3600,
|
|
32
|
+
stampedeProtection: true,
|
|
33
|
+
sqlite: { path: ":memory:" },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Create a typed cache configuration object with defaults.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* import { CacheConfig } from '@zerotal/cache';
|
|
41
|
+
* export default CacheConfig({ driver: 'sqlite', ttl: 600 });
|
|
42
|
+
*/
|
|
43
|
+
export function CacheConfig(options: Partial<CacheConfigShape> = {}): CacheConfigShape {
|
|
44
|
+
return deepMerge(defaults, options);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Register this package's config namespace for typed config() dot-paths.
|
|
48
|
+
declare module "@zerotal/core" {
|
|
49
|
+
interface ConfigRegistry {
|
|
50
|
+
cache: CacheConfigShape;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface CacheDriver {
|
|
2
|
+
/**
|
|
3
|
+
* Retrieve a cached value. Returns null if the key does not exist
|
|
4
|
+
* or has expired.
|
|
5
|
+
*/
|
|
6
|
+
get(key: string): Promise<string | null>;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Store a value. ttl is in seconds; omit for no expiry.
|
|
10
|
+
*/
|
|
11
|
+
set(key: string, value: string, ttl?: number): Promise<void>;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Remove a single key.
|
|
15
|
+
*/
|
|
16
|
+
forget(key: string): Promise<void>;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Check whether a key exists and has not expired.
|
|
20
|
+
*/
|
|
21
|
+
has(key: string): Promise<boolean>;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Remove all keys matching the given prefix.
|
|
25
|
+
* Used by TaggedCache to flush a tag group.
|
|
26
|
+
*/
|
|
27
|
+
flush(prefix?: string): Promise<void>;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Release any background resources (timers, connections).
|
|
31
|
+
* Called on graceful shutdown by CacheManager.dispose().
|
|
32
|
+
*/
|
|
33
|
+
dispose?(): void;
|
|
34
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { CacheDriver } from "./CacheDriver.ts";
|
|
2
|
+
import { FrameworkEvents } from "@zerotal/core";
|
|
3
|
+
import { CacheEvicted } from "../events.ts";
|
|
4
|
+
|
|
5
|
+
interface Entry {
|
|
6
|
+
value: string;
|
|
7
|
+
expiresAt: number | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class MemoryDriver implements CacheDriver {
|
|
11
|
+
private _store = new Map<string, Entry>();
|
|
12
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
13
|
+
private _sweepInterval: any;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param sweepMs - How often to run the background sweep (ms).
|
|
17
|
+
* Defaults to 5 minutes. Pass 0 to disable auto-sweeping.
|
|
18
|
+
*/
|
|
19
|
+
constructor(sweepMs = 5 * 60 * 1_000) {
|
|
20
|
+
if (sweepMs > 0) {
|
|
21
|
+
this._sweepInterval = setInterval(() => this.sweep(), sweepMs);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async get(key: string): Promise<string | null> {
|
|
26
|
+
const entry = this._store.get(key);
|
|
27
|
+
if (!entry) return null;
|
|
28
|
+
if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
|
|
29
|
+
this._store.delete(key);
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return entry.value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async set(key: string, value: string, ttl?: number): Promise<void> {
|
|
36
|
+
const expiresAt = ttl === undefined ? null : Date.now() + ttl * 1000;
|
|
37
|
+
this._store.set(key, { value, expiresAt });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async forget(key: string): Promise<void> {
|
|
41
|
+
this._store.delete(key);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async has(key: string): Promise<boolean> {
|
|
45
|
+
const entry = this._store.get(key);
|
|
46
|
+
if (!entry) return false;
|
|
47
|
+
if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) {
|
|
48
|
+
this._store.delete(key);
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async flush(prefix?: string): Promise<void> {
|
|
55
|
+
if (prefix === undefined) {
|
|
56
|
+
this._store.clear();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
for (const key of this._store.keys()) {
|
|
60
|
+
if (key.startsWith(prefix)) this._store.delete(key);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Proactively remove all expired entries from the internal store.
|
|
66
|
+
*
|
|
67
|
+
* Called automatically on the sweep interval. Also callable manually
|
|
68
|
+
* (e.g. before taking a memory snapshot or in tests).
|
|
69
|
+
*/
|
|
70
|
+
sweep(): void {
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
for (const [key, entry] of this._store.entries()) {
|
|
73
|
+
if (entry.expiresAt !== null && entry.expiresAt <= now) {
|
|
74
|
+
this._store.delete(key);
|
|
75
|
+
FrameworkEvents.emit(new CacheEvicted(key, "ttl"));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Number of entries currently in the store (including expired-but-unswept). */
|
|
81
|
+
get size(): number {
|
|
82
|
+
return this._store.size;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Stop the background sweep interval. Call during graceful shutdown. */
|
|
86
|
+
dispose(): void {
|
|
87
|
+
clearInterval(this._sweepInterval);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { redis } from "bun";
|
|
2
|
+
import type { CacheDriver } from "./CacheDriver.ts";
|
|
3
|
+
|
|
4
|
+
export class RedisDriver implements CacheDriver {
|
|
5
|
+
private _prefix: string;
|
|
6
|
+
|
|
7
|
+
constructor(prefix = "") {
|
|
8
|
+
this._prefix = prefix;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
private _key(key: string): string {
|
|
12
|
+
return this._prefix + key;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async get(key: string): Promise<string | null> {
|
|
16
|
+
return redis.get(this._key(key));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async set(key: string, value: string, ttl?: number): Promise<void> {
|
|
20
|
+
const namespaced = this._key(key);
|
|
21
|
+
if (ttl === undefined) {
|
|
22
|
+
await redis.set(namespaced, value);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
await redis.set(namespaced, value, "EX", ttl);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async forget(key: string): Promise<void> {
|
|
29
|
+
await redis.del(this._key(key));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async has(key: string): Promise<boolean> {
|
|
33
|
+
return (await this.get(key)) !== null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async flush(prefix?: string): Promise<void> {
|
|
37
|
+
const pattern = this._key(prefix ?? "") + "*";
|
|
38
|
+
// Iterate with SCAN rather than the blocking KEYS: KEYS walks the whole
|
|
39
|
+
// keyspace in one shot and stalls the server on large DBs. SCAN is cursored
|
|
40
|
+
// and non-blocking. The cache prefix (`zerotal:cache:`) is disjoint from the
|
|
41
|
+
// queue's `zerotal:jobs:`, so this never touches queued jobs.
|
|
42
|
+
let cursor = "0";
|
|
43
|
+
do {
|
|
44
|
+
const reply = (await redis.send("SCAN", [cursor, "MATCH", pattern, "COUNT", "100"])) as
|
|
45
|
+
[string, string[]] | null;
|
|
46
|
+
if (!reply) break;
|
|
47
|
+
cursor = reply[0];
|
|
48
|
+
const batch = reply[1] ?? [];
|
|
49
|
+
for (let i = 0; i < batch.length; i += 100) {
|
|
50
|
+
const slice = batch.slice(i, i + 100);
|
|
51
|
+
if (slice.length > 0) await redis.del(...slice);
|
|
52
|
+
}
|
|
53
|
+
} while (cursor !== "0");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { CacheDriver } from "./CacheDriver.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal SQL connection shape this driver needs — a callable tagged-template that
|
|
5
|
+
* runs a query. Declared locally so `@zerotal/cache` stays decoupled from the ORM;
|
|
6
|
+
* any Bun `SQL` instance (or the ORM's `SQLInstance`) satisfies it structurally.
|
|
7
|
+
*/
|
|
8
|
+
interface SqlConnection {
|
|
9
|
+
<T = Record<string, unknown>>(strings: TemplateStringsArray, ...values: unknown[]): Promise<T[]>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class SqliteDriver implements CacheDriver {
|
|
13
|
+
private _sql: SqlConnection;
|
|
14
|
+
private _ready: Promise<void>;
|
|
15
|
+
|
|
16
|
+
constructor(sqlInstance: SqlConnection) {
|
|
17
|
+
this._sql = sqlInstance;
|
|
18
|
+
this._ready = this._ensureTable();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
private async _ensureTable(): Promise<void> {
|
|
22
|
+
await this._sql`
|
|
23
|
+
CREATE TABLE IF NOT EXISTS zerotal_cache (
|
|
24
|
+
key TEXT PRIMARY KEY,
|
|
25
|
+
value TEXT NOT NULL,
|
|
26
|
+
expires_at INTEGER
|
|
27
|
+
)
|
|
28
|
+
`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async get(key: string): Promise<string | null> {
|
|
32
|
+
await this._ready;
|
|
33
|
+
const now = Math.floor(Date.now() / 1000);
|
|
34
|
+
const rows = await this._sql<{ value: string }>`
|
|
35
|
+
SELECT value
|
|
36
|
+
FROM zerotal_cache
|
|
37
|
+
WHERE key = ${key}
|
|
38
|
+
AND (expires_at IS NULL OR expires_at > ${now})
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
if (rows.length === 0) {
|
|
42
|
+
await this._sql`
|
|
43
|
+
DELETE FROM zerotal_cache
|
|
44
|
+
WHERE key = ${key}
|
|
45
|
+
AND expires_at IS NOT NULL
|
|
46
|
+
AND expires_at <= ${now}
|
|
47
|
+
`;
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return rows[0]?.value ?? null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async set(key: string, value: string, ttl?: number): Promise<void> {
|
|
55
|
+
await this._ready;
|
|
56
|
+
const expiresAt = ttl === undefined ? null : Math.floor(Date.now() / 1000) + ttl;
|
|
57
|
+
|
|
58
|
+
await this._sql`
|
|
59
|
+
INSERT OR REPLACE INTO zerotal_cache (key, value, expires_at)
|
|
60
|
+
VALUES (${key}, ${value}, ${expiresAt})
|
|
61
|
+
`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async forget(key: string): Promise<void> {
|
|
65
|
+
await this._ready;
|
|
66
|
+
await this._sql`
|
|
67
|
+
DELETE FROM zerotal_cache
|
|
68
|
+
WHERE key = ${key}
|
|
69
|
+
`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async has(key: string): Promise<boolean> {
|
|
73
|
+
await this._ready;
|
|
74
|
+
const now = Math.floor(Date.now() / 1000);
|
|
75
|
+
const rows = await this._sql`
|
|
76
|
+
SELECT 1 AS found
|
|
77
|
+
FROM zerotal_cache
|
|
78
|
+
WHERE key = ${key}
|
|
79
|
+
AND (expires_at IS NULL OR expires_at > ${now})
|
|
80
|
+
LIMIT 1
|
|
81
|
+
`;
|
|
82
|
+
|
|
83
|
+
return rows.length > 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async flush(prefix?: string): Promise<void> {
|
|
87
|
+
await this._ready;
|
|
88
|
+
if (prefix === undefined) {
|
|
89
|
+
await this._sql`DELETE FROM zerotal_cache`;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const pattern = `${prefix}%`;
|
|
94
|
+
await this._sql`
|
|
95
|
+
DELETE FROM zerotal_cache
|
|
96
|
+
WHERE key LIKE ${pattern}
|
|
97
|
+
`;
|
|
98
|
+
}
|
|
99
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ZerotalError } from "@zerotal/core";
|
|
2
|
+
|
|
3
|
+
/** Base class for all @zerotal/cache errors. */
|
|
4
|
+
export class CacheError extends ZerotalError {
|
|
5
|
+
constructor(message: string, code = "E_CACHE", status = 500, context?: Record<string, unknown>) {
|
|
6
|
+
super(message, code, status, context);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Thrown when a value cannot be JSON-serialized for storage. */
|
|
11
|
+
export class CacheSerializationError extends CacheError {
|
|
12
|
+
constructor(key: string, cause: unknown) {
|
|
13
|
+
super(
|
|
14
|
+
`[Zerotal Cache] Failed to serialize value for key "${key}": ${(cause as Error)?.message ?? String(cause)}`,
|
|
15
|
+
"E_CACHE_SERIALIZATION",
|
|
16
|
+
500,
|
|
17
|
+
{ key },
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Thrown when a cached value cannot be JSON-parsed on read. */
|
|
23
|
+
export class CacheDeserializationError extends CacheError {
|
|
24
|
+
constructor(key: string, cause: unknown) {
|
|
25
|
+
super(
|
|
26
|
+
`[Zerotal Cache] Failed to deserialize cached value for key "${key}": ${(cause as Error)?.message ?? String(cause)}`,
|
|
27
|
+
"E_CACHE_DESERIALIZATION",
|
|
28
|
+
500,
|
|
29
|
+
{ key },
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cache package's framework events, emitted on core's {@link FrameworkEvents}
|
|
3
|
+
* bus. Observability packages subscribe to them by kind (their class name).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Emitted after a cache read, write, delete, flush, or existence check. `op`
|
|
8
|
+
* records which operation ran (a read is either `hit` or `miss`).
|
|
9
|
+
*
|
|
10
|
+
* @category Cache
|
|
11
|
+
*/
|
|
12
|
+
export class CacheQueried {
|
|
13
|
+
constructor(
|
|
14
|
+
readonly op: "hit" | "miss" | "write" | "forget" | "flush" | "has",
|
|
15
|
+
readonly key: string,
|
|
16
|
+
readonly ttl: number | undefined,
|
|
17
|
+
readonly durationMs: number,
|
|
18
|
+
) {}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Emitted when a cache entry is removed by the driver — `reason` distinguishes
|
|
23
|
+
* TTL expiry, capacity eviction, and manual removal.
|
|
24
|
+
*
|
|
25
|
+
* @category Cache
|
|
26
|
+
*/
|
|
27
|
+
export class CacheEvicted {
|
|
28
|
+
constructor(
|
|
29
|
+
readonly key: string,
|
|
30
|
+
readonly reason: "ttl" | "capacity" | "manual",
|
|
31
|
+
) {}
|
|
32
|
+
}
|
package/src/global.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Ambient declarations specific to this package.
|
|
2
|
+
// Bun, Node (node:*), and bun:test types come from @types/bun (→ bun-types).
|
|
3
|
+
// Only declarations bun-types does NOT provide are kept here.
|
|
4
|
+
|
|
5
|
+
// ── Bun globals ───────────────────────────────────────────────────────────
|
|
6
|
+
// Bun extends Request with native route params (e.g. /users/:id → { id: '42' })
|
|
7
|
+
interface Request {
|
|
8
|
+
readonly params?: Record<string, string>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// ── SQLInstance: callable tagged-template + DB methods ────────────────────
|
|
12
|
+
interface SQLInstance {
|
|
13
|
+
<T = Record<string, unknown>>(
|
|
14
|
+
strings: TemplateStringsArray,
|
|
15
|
+
...values: unknown[]
|
|
16
|
+
): Promise<T[]>;
|
|
17
|
+
begin<T>(fn: (tx: SQLInstance) => Promise<T>): Promise<T>;
|
|
18
|
+
end(): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ── RedisInstance: minimal Bun.redis surface ──────────────────────────────
|
|
22
|
+
interface RedisInstance {
|
|
23
|
+
get(key: string): Promise<string | null>;
|
|
24
|
+
set(key: string, value: string): Promise<void>;
|
|
25
|
+
set(key: string, value: string, options: { ex: number }): Promise<void>;
|
|
26
|
+
del(...keys: string[]): Promise<number>;
|
|
27
|
+
expire(key: string, seconds: number): Promise<number>;
|
|
28
|
+
keys(pattern: string): Promise<string[]>;
|
|
29
|
+
flushdb(): Promise<void>;
|
|
30
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// @zerotal/cache — public API barrel
|
|
2
|
+
|
|
3
|
+
export { CacheManager, TaggedCache } from "./CacheManager.ts";
|
|
4
|
+
export { IdempotencyMiddleware } from "./IdempotencyMiddleware.ts";
|
|
5
|
+
export type { IdempotencyOptions } from "./IdempotencyMiddleware.ts";
|
|
6
|
+
export { CacheProvider } from "./provider/CacheProvider.ts";
|
|
7
|
+
export { Cache } from "./facades/Cache.ts";
|
|
8
|
+
|
|
9
|
+
// Driver exports — for custom driver registration
|
|
10
|
+
export type { CacheDriver } from "./drivers/CacheDriver.ts";
|
|
11
|
+
export { MemoryDriver } from "./drivers/MemoryDriver.ts";
|
|
12
|
+
export { SqliteDriver } from "./drivers/SqliteDriver.ts";
|
|
13
|
+
export { RedisDriver } from "./drivers/RedisDriver.ts";
|
|
14
|
+
|
|
15
|
+
// Config factory
|
|
16
|
+
export { CacheConfig } from "./config.ts";
|
|
17
|
+
export type { CacheConfigShape } from "./config.ts";
|
|
18
|
+
|
|
19
|
+
// Typed error vocabulary
|
|
20
|
+
export * from "./errors.ts";
|
|
21
|
+
|
|
22
|
+
// Framework instrumentation events (emitted on the core FrameworkEvents bus)
|
|
23
|
+
export { CacheQueried, CacheEvicted } from "./events.ts";
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache → observer bridges. The cache emits its own `CacheQueried` / `CacheEvicted`
|
|
3
|
+
* framework events on the core `FrameworkEvents` bus; this module forwards them to
|
|
4
|
+
* whichever observer packages are installed.
|
|
5
|
+
*
|
|
6
|
+
* Each observer's write surface is resolved from the container by binding key and
|
|
7
|
+
* typed through a local structural interface, so the cache depends on none of the
|
|
8
|
+
* observer packages — installing or removing an observer requires no change here.
|
|
9
|
+
*/
|
|
10
|
+
import { FrameworkEvents, RequestContext } from "@zerotal/core";
|
|
11
|
+
import type { Application } from "@zerotal/core";
|
|
12
|
+
import { CacheQueried, CacheEvicted } from "./events.ts";
|
|
13
|
+
|
|
14
|
+
/** The subset of the monitor store this bridge calls (bound as `monitor.store`). */
|
|
15
|
+
interface MonitorSink {
|
|
16
|
+
recordCache(hit: boolean, key?: string): void;
|
|
17
|
+
recordEvent(e: {
|
|
18
|
+
kind: string;
|
|
19
|
+
label: string;
|
|
20
|
+
status?: "ok" | "warn" | "bad" | "info";
|
|
21
|
+
route?: string | null;
|
|
22
|
+
data?: Record<string, unknown>;
|
|
23
|
+
}): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The subset of the devtools trace sink this bridge calls (bound as `devtools.trace`). */
|
|
27
|
+
interface DevtoolsSink {
|
|
28
|
+
bufferCache(
|
|
29
|
+
ctx: object,
|
|
30
|
+
c: {
|
|
31
|
+
op: "has" | "hit" | "miss" | "write" | "forget" | "flush";
|
|
32
|
+
key: string;
|
|
33
|
+
ttl?: number | undefined;
|
|
34
|
+
durationMs: number;
|
|
35
|
+
},
|
|
36
|
+
): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Subscribe the cache's events to every installed observer. Returns a disposer that
|
|
41
|
+
* removes every subscription; call it from the cache provider's `onStopping()`.
|
|
42
|
+
*/
|
|
43
|
+
export function installCacheObservability(app: Application): () => void {
|
|
44
|
+
const unsubs: Array<() => void> = [];
|
|
45
|
+
|
|
46
|
+
const store = app.container.tryMake("monitor.store" as never) as MonitorSink | undefined;
|
|
47
|
+
if (store) {
|
|
48
|
+
unsubs.push(
|
|
49
|
+
FrameworkEvents.on(CacheQueried, (e) => {
|
|
50
|
+
if (e.op === "hit") store.recordCache(true, e.key);
|
|
51
|
+
else if (e.op === "miss") store.recordCache(false, e.key);
|
|
52
|
+
}),
|
|
53
|
+
FrameworkEvents.on(CacheEvicted, (e) =>
|
|
54
|
+
store.recordEvent({
|
|
55
|
+
kind: "cache_evict",
|
|
56
|
+
label: e.key,
|
|
57
|
+
status: "info",
|
|
58
|
+
route: null,
|
|
59
|
+
data: { detail: e.reason },
|
|
60
|
+
}),
|
|
61
|
+
),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const trace = app.container.tryMake("devtools.trace" as never) as DevtoolsSink | undefined;
|
|
66
|
+
if (trace) {
|
|
67
|
+
unsubs.push(
|
|
68
|
+
FrameworkEvents.on(CacheQueried, (e) => {
|
|
69
|
+
const ctx = RequestContext.tryGet();
|
|
70
|
+
if (!ctx) return;
|
|
71
|
+
trace.bufferCache(ctx, { op: e.op, key: e.key, ttl: e.ttl, durationMs: e.durationMs });
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return () => {
|
|
77
|
+
for (const unsub of unsubs) unsub();
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { ServiceProvider } from "@zerotal/core";
|
|
2
|
+
import type { AppEnvironment } from "@zerotal/core";
|
|
3
|
+
import type { ConfigManager } from "@zerotal/core/config";
|
|
4
|
+
import type { LockManager } from "@zerotal/core/lock";
|
|
5
|
+
import { CacheManager } from "../CacheManager.ts";
|
|
6
|
+
import { MemoryDriver } from "../drivers/MemoryDriver.ts";
|
|
7
|
+
import { SqliteDriver } from "../drivers/SqliteDriver.ts";
|
|
8
|
+
import { RedisDriver } from "../drivers/RedisDriver.ts";
|
|
9
|
+
import { installCacheObservability } from "../observability.ts";
|
|
10
|
+
import { SQL } from "bun";
|
|
11
|
+
|
|
12
|
+
// Extend the core container registry
|
|
13
|
+
declare module "@zerotal/core" {
|
|
14
|
+
interface ContainerBindings {
|
|
15
|
+
cache: CacheManager;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class CacheProvider extends ServiceProvider {
|
|
20
|
+
static override provides = ["cache"] as const;
|
|
21
|
+
static override environments: AppEnvironment[] = ["web", "console", "test", "repl"];
|
|
22
|
+
|
|
23
|
+
private _disposeObservability: (() => void) | undefined = undefined;
|
|
24
|
+
|
|
25
|
+
override onRegister(): void {
|
|
26
|
+
this.app.container.singleton("cache", () => {
|
|
27
|
+
const config = this.app.container.makeSync("config") as ConfigManager;
|
|
28
|
+
|
|
29
|
+
const driver = config.get<string>("cache.driver", "sqlite");
|
|
30
|
+
const prefix = config.get<string>("cache.prefix", "zerotal:");
|
|
31
|
+
const ttl = config.get<number>("cache.ttl", 3600);
|
|
32
|
+
const sqlitePath = config.get<string>("cache.sqlite.path", ":memory:");
|
|
33
|
+
const stampede = config.get<boolean>("cache.stampedeProtection", true);
|
|
34
|
+
|
|
35
|
+
// Cross-process stampede protection uses the lock primitive when LockProvider
|
|
36
|
+
// is registered; otherwise remember() degrades to in-process coalescing.
|
|
37
|
+
const lock = (this.app.container.tryMake("lock") as LockManager | null) ?? null;
|
|
38
|
+
|
|
39
|
+
switch (driver) {
|
|
40
|
+
case "redis":
|
|
41
|
+
return new CacheManager(new RedisDriver(prefix), "", ttl, lock, stampede);
|
|
42
|
+
|
|
43
|
+
case "memory":
|
|
44
|
+
return new CacheManager(new MemoryDriver(), prefix, ttl, lock, stampede);
|
|
45
|
+
|
|
46
|
+
case "sqlite":
|
|
47
|
+
default: {
|
|
48
|
+
const sqlInstance = new SQL(_toSqliteUrl(sqlitePath));
|
|
49
|
+
return new CacheManager(new SqliteDriver(sqlInstance), prefix, ttl, lock, stampede);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
override async onBooted(): Promise<void> {
|
|
56
|
+
// Pre-resolve so the Cache facade (makeSync) works after boot.
|
|
57
|
+
await this.app.container.make("cache");
|
|
58
|
+
|
|
59
|
+
this._disposeObservability = installCacheObservability(this.app);
|
|
60
|
+
|
|
61
|
+
const runner = this.app.container.tryMake("commands");
|
|
62
|
+
if (!runner) return;
|
|
63
|
+
|
|
64
|
+
runner.registerLazy("cache:clear", () =>
|
|
65
|
+
import("../commands/CacheClearCommand.ts").then((m) => m.CacheClearCommand),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
override async onStopping(): Promise<void> {
|
|
70
|
+
this._disposeObservability?.();
|
|
71
|
+
this._disposeObservability = undefined;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Bun v1.3.x on Windows only opens SQLite when the URL starts with 'sqlite:'
|
|
76
|
+
// or is ':memory:'. Bare file paths are treated as PostgreSQL URLs.
|
|
77
|
+
function _toSqliteUrl(path: string): string {
|
|
78
|
+
if (!path || path === ":memory:") return path;
|
|
79
|
+
if (path.startsWith("sqlite:")) return path;
|
|
80
|
+
if (path.startsWith("sqlite://")) return path.replace("sqlite://", "sqlite:");
|
|
81
|
+
if (path.startsWith("file:")) return "sqlite:" + path.slice("file:".length);
|
|
82
|
+
return "sqlite:" + path;
|
|
83
|
+
}
|