@lenso/cache 0.0.0-stage → 0.1.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/README.md +237 -2
- package/dist/config.d.ts +5 -0
- package/dist/contracts.d.ts +88 -0
- package/dist/index-gp63f5x9.js +57 -0
- package/dist/index-pmwehpb3.js +326 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +12 -0
- package/dist/json.d.ts +6 -0
- package/dist/memory.d.ts +8 -0
- package/dist/memory.js +133 -0
- package/dist/plugin.d.ts +28 -0
- package/dist/plugin.js +82 -0
- package/dist/redis.d.ts +7 -0
- package/dist/redis.js +152 -0
- package/package.json +47 -4
package/README.md
CHANGED
|
@@ -1,3 +1,238 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @lenso/cache
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Typed finite-TTL JSON caching. The ordinary service is independent of Lenso.
|
|
4
|
+
`/memory`, `/redis` and `/plugin` are separate entries; the root entry imports
|
|
5
|
+
neither Bun Redis nor Lenso, Auth, DB, Manage, Tasks, Log or OTel. Core is an
|
|
6
|
+
optional peer used only by `/plugin`. No SQL migration is needed.
|
|
7
|
+
|
|
8
|
+
## Ordinary service
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { createCache } from "@lenso/cache";
|
|
12
|
+
import { createMemoryCacheAdapter } from "@lenso/cache/memory";
|
|
13
|
+
|
|
14
|
+
const cache = createCache<string | null>({
|
|
15
|
+
namespace: "catalog:labels",
|
|
16
|
+
adapter: createMemoryCacheAdapter({ maxEntries: 1000 }),
|
|
17
|
+
validate: (value): value is string | null => value === null || typeof value === "string",
|
|
18
|
+
});
|
|
19
|
+
await cache.set("label:42", null, { ttlMs: 30_000 });
|
|
20
|
+
const result = await cache.get("label:42");
|
|
21
|
+
// { status: "hit", value: null }, not a miss
|
|
22
|
+
const tenant = cache.scope("tenant:42");
|
|
23
|
+
const value = await tenant.getOrSet("public-label", async (signal) => {
|
|
24
|
+
signal.throwIfAborted();
|
|
25
|
+
return "Local fixture";
|
|
26
|
+
});
|
|
27
|
+
await tenant.delete("public-label");
|
|
28
|
+
await tenant.invalidate();
|
|
29
|
+
cache.close();
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`get`, `set`, `delete`, `getMany`, `scope`, `invalidate`, `getOrSet` are async
|
|
33
|
+
business methods (except scope creation/close). `getMany` accepts at most 100
|
|
34
|
+
keys, preserves order and duplicates, and returns a per-key hit, miss or error.
|
|
35
|
+
It is not a transactional snapshot across adapters.
|
|
36
|
+
|
|
37
|
+
## TTL, isolation and serialization
|
|
38
|
+
|
|
39
|
+
- TTL units are **integer milliseconds**. Default: 60,000. All stored entries
|
|
40
|
+
have a positive finite TTL; default maximum: 86,400,000 (24 hours), which
|
|
41
|
+
applications may lower, not raise. Negative, fractional, non-finite and
|
|
42
|
+
above-maximum TTLs are rejected. No immortal-entry mode.
|
|
43
|
+
- `set(..., {ttlMs: 0})` deletes the old entry and returns `skipped`, rather than
|
|
44
|
+
storing forever. A zero default applies this rule too. `getOrSet` with zero
|
|
45
|
+
ignores/removes an existing hit, loads fresh, and does not store its result.
|
|
46
|
+
`get` still reads entries created with an explicit positive TTL.
|
|
47
|
+
- Expiry starts when the service serializes the entry before writing. Both
|
|
48
|
+
adapter expiry and the versioned JSON envelope's `expiresAt <= Date.now()`
|
|
49
|
+
reject expired reads. Redis physical expiry may be later because of transport
|
|
50
|
+
latency. Hosts must have synchronized clocks; clock jumps/skew can shorten or
|
|
51
|
+
extend effective lifetime. This is not a monotonic time guarantee.
|
|
52
|
+
- Namespaces, scope segments and keys are nonempty well-formed Unicode,
|
|
53
|
+
<=256 UTF-8 bytes, without control characters. Paths are collision-safe and
|
|
54
|
+
limited to 16 segments including the root. Use application-controlled scopes,
|
|
55
|
+
not arbitrary request-driven namespace churn. `scope("a:b")` differs from
|
|
56
|
+
`scope("a").scope("b")`.
|
|
57
|
+
- `invalidate()` affects **only the exact current scope**, not descendants,
|
|
58
|
+
parents, another plugin, or the whole backend. There is no flush-all, wildcard
|
|
59
|
+
invalidation or key listing. Plugins sharing an explicit namespace intentionally
|
|
60
|
+
share values; a namespace is isolation by convention, not authorization.
|
|
61
|
+
- Values allow null, booleans, strings, finite numbers, dense ordinary arrays,
|
|
62
|
+
and plain/null-prototype objects containing those types. JSON normalizes `-0`
|
|
63
|
+
to `0`. Reject undefined (including object fields), BigInt, Date, Map/Set,
|
|
64
|
+
class instances, symbols, functions, accessors, custom array prototypes, sparse
|
|
65
|
+
arrays and cycles. Maximum nesting: 64. Values are copied by serialization.
|
|
66
|
+
- Default maximum is 65,536 UTF-8 bytes **including the JSON envelope**, tunable
|
|
67
|
+
up to 1 MiB. Optional `validate` is a runtime type guard for writes and reads;
|
|
68
|
+
a TypeScript generic alone does not verify data from another writer. Different
|
|
69
|
+
scope types should provide their own guard and keys/schema versions.
|
|
70
|
+
- Malformed JSON, unsupported envelope versions, oversized entries or failed
|
|
71
|
+
read guards become `{status:"miss", reason:"corrupt"}`. The entry is not
|
|
72
|
+
returned or eagerly deleted (a concurrent writer could have replaced it);
|
|
73
|
+
it expires normally or is replaced by a later load. Invalid writes throw a
|
|
74
|
+
sanitized `CacheError("serialization")` in both failure modes.
|
|
75
|
+
|
|
76
|
+
## Adapters and actual guarantees
|
|
77
|
+
|
|
78
|
+
| Adapter | Visibility | Capacity / invalidation |
|
|
79
|
+
| --------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
80
|
+
| Memory | Only users of the same adapter object in one process | LRU, defaults 1000 entries, 8 MiB approximate serialized key/namespace/generation/value bytes, 128 namespace tokens. Expired reads remove entries. Namespace eviction removes its entries and uses a fresh token. Invalidation traverses at most the bounded entry map. No timers. |
|
|
81
|
+
| Bun Redis | Instances connected to the same standalone Redis primary and prefix | Parameterized Lua fences namespace generations; data keys include the generation. Invalidation changes one control key, no scanning. Old-generation entries expire within their finite TTL. Control keys persist: bound the number of provisioned scopes operationally. Redis memory/eviction policy belongs to its operator. |
|
|
82
|
+
|
|
83
|
+
Memory byte accounting is not a JavaScript heap measurement. Names and entry
|
|
84
|
+
counts also have limits. Oversized single entries can exceed the adapter byte
|
|
85
|
+
budget independently of the service envelope limit and cause a write failure.
|
|
86
|
+
Backend limits should be aligned with `maxValueBytes`.
|
|
87
|
+
|
|
88
|
+
Redis uses Bun's built-in `RedisClient` and requires no third-party Redis
|
|
89
|
+
dependency. Its documented target is Bun with Redis >=7.2. Local validation used
|
|
90
|
+
Bun 1.4.2 / Redis 8.10.2, not every Redis version. No Redis Cluster, Sentinel,
|
|
91
|
+
replica-read, failover durability, global ordering or exactly-once claim. Across
|
|
92
|
+
instances, reads/writes fence completed namespace invalidation at the primary;
|
|
93
|
+
operations already in flight can still return their earlier results. Deleted or
|
|
94
|
+
evicted generation metadata is recreated with a new UUID, never a fallback
|
|
95
|
+
generation that could resurrect old values.
|
|
96
|
+
|
|
97
|
+
Individual getMany WRONGTYPE failures leave healthy keys intact. Whole-command
|
|
98
|
+
or generation-read failure marks every affected item failed. No atomic bulk
|
|
99
|
+
write API or automatic write retry is supplied; a lost connection may leave a
|
|
100
|
+
write's outcome unknown. Concurrent writes to a key are ordinary last backend
|
|
101
|
+
write wins. Cross-instance `delete` does not fence another instance's loader:
|
|
102
|
+
use namespace invalidation when those loads must not repopulate a scope.
|
|
103
|
+
|
|
104
|
+
## Failures, loading and lifetime
|
|
105
|
+
|
|
106
|
+
`failureMode` defaults to `fail-closed`: single reads/writes/delete/invalidate
|
|
107
|
+
throw sanitized `CacheError("backend")`; batches expose per-key errors.
|
|
108
|
+
`fail-open` explicitly turns backend reads into misses with reason `backend`,
|
|
109
|
+
and mutations into `bypassed`. `getOrSet` then loads from the authoritative
|
|
110
|
+
source without substituting a local cache. Write results are `stored`,
|
|
111
|
+
`skipped`, `superseded` (generation changed) or `bypassed`. Source loader errors
|
|
112
|
+
are business errors, propagate unchanged, and are never cached or logged here.
|
|
113
|
+
|
|
114
|
+
`getOrSet` merges loads by exact scope/key **within one service family**, not
|
|
115
|
+
across independently created services, processes or Redis clients. First caller
|
|
116
|
+
chooses loader/TTL; all other callers must use the same semantic loader. Each
|
|
117
|
+
waiter receives an independent JSON copy. One waiter's cancellation only rejects
|
|
118
|
+
that waiter. All cancelled waiters abort the shared loader signal, detach that
|
|
119
|
+
load from new callers and prevent its result being written. Loader cancellation
|
|
120
|
+
is cooperative: a loader ignoring the signal keeps consuming an in-flight slot
|
|
121
|
+
until it settles. Default maximum: 128 active loads across scopes; overflow
|
|
122
|
+
throws `busy`. Closing aborts loads and rejects subsequent operations across
|
|
123
|
+
the service family, never closes the adapter. Cancelled waiter records are
|
|
124
|
+
removed immediately; only one settlement subscription is retained per load.
|
|
125
|
+
The host remains responsible for bounding concurrent live requests/waiters.
|
|
126
|
+
|
|
127
|
+
Within the family, set/delete/exact invalidation detach and suppress earlier
|
|
128
|
+
load writes. Namespace generation checks suppress late load writes across
|
|
129
|
+
instances too. These are not distributed locks. An already dispatched backend
|
|
130
|
+
command cannot be recalled by cancellation/close; writes that raced an operation
|
|
131
|
+
may complete. A caller already loading may receive its earlier result after
|
|
132
|
+
invalidation. Do not use this cache as a financial ledger, idempotency record,
|
|
133
|
+
session store or permission source of truth.
|
|
134
|
+
|
|
135
|
+
`onEvent` emits only operation and `backend`/`corrupt` reason. Observer errors
|
|
136
|
+
do not alter results. No values, driver errors, credentials or keys are logged.
|
|
137
|
+
The optional plugin sends these fields to Lenso's existing logger; application
|
|
138
|
+
Log/OTel setup remains unchanged. Avoid sensitive span attributes in callers.
|
|
139
|
+
|
|
140
|
+
## Optional plugin and owned Redis connection
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
import { RedisClient } from "bun";
|
|
144
|
+
import { definePlugin, type Plugin } from "@lenso/core/plugin";
|
|
145
|
+
import { createRedisCacheAdapter } from "@lenso/cache/redis";
|
|
146
|
+
import { createCachePlugin } from "@lenso/cache/plugin";
|
|
147
|
+
import type { CacheAdapter } from "@lenso/cache";
|
|
148
|
+
|
|
149
|
+
const redisAdapter: Plugin<CacheAdapter> = definePlugin({
|
|
150
|
+
id: "catalog-redis",
|
|
151
|
+
async setup(context) {
|
|
152
|
+
// Supply CACHE_REDIS_URL through trusted configuration, not committed code.
|
|
153
|
+
const url = process.env.CACHE_REDIS_URL;
|
|
154
|
+
if (!url) throw new Error("CACHE_REDIS_URL is required");
|
|
155
|
+
const client = new RedisClient(url, {
|
|
156
|
+
connectionTimeout: 1000,
|
|
157
|
+
autoReconnect: false,
|
|
158
|
+
enableOfflineQueue: false,
|
|
159
|
+
maxRetries: 0,
|
|
160
|
+
});
|
|
161
|
+
context.onCleanup(() => client.close()); // register before connecting
|
|
162
|
+
await client.connect();
|
|
163
|
+
return createRedisCacheAdapter({ client, prefix: "catalog-cache" });
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
const cachePlugin = createCachePlugin({
|
|
167
|
+
id: "catalog-cache",
|
|
168
|
+
adapter: redisAdapter, // exact installed instance
|
|
169
|
+
config: { namespace: "catalog:public", failureMode: "fail-closed" },
|
|
170
|
+
});
|
|
171
|
+
// Install both instances. Consumers use requires: [cachePlugin] and context.get(cachePlugin).
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The adapter borrows its client. If a DB/resource plugin owns a driver, keep
|
|
175
|
+
cleanup with that plugin, not the cache. No resource acquisition at module
|
|
176
|
+
top level. The cache plugin uses existing `definePluginConfig`/`bindConfig`;
|
|
177
|
+
`config` also accepts ordered Config sources (`valuesSource`, explicit
|
|
178
|
+
`envSource`, etc.) and resolves before any service setup. Exported `cacheConfig`
|
|
179
|
+
provides a Standard Schema contract and explicit JSON Schema converter.
|
|
180
|
+
Source failures are never made fail-open by cache policy.
|
|
181
|
+
|
|
182
|
+
No Manage, HTTP, CLI, MCP or Auth operation is automatically exposed. Management
|
|
183
|
+
is absent/disabled by default. If an application exposes delete/invalidate, it
|
|
184
|
+
must choose an explicit operation allowlist, obtain a trusted identity at the
|
|
185
|
+
entry, and authorize the exact scope in its ordinary service with existing Auth
|
|
186
|
+
and Manage. Business JSON is not identity. Do not expose raw values or an
|
|
187
|
+
arbitrary backend prefix/namespace selector to remote clients.
|
|
188
|
+
|
|
189
|
+
Permission decisions are not cached by any integration here. Authenticate and
|
|
190
|
+
authorize every request against existing Auth/fresh authority, even for cached
|
|
191
|
+
business projections; a TTL cannot guarantee timely revocation. Drizzle remains
|
|
192
|
+
the authoritative store; apply cache invalidation only after a successful DB
|
|
193
|
+
change, using existing Tasks if retry/outbox delivery is needed. No implicit
|
|
194
|
+
transaction coupling, task registration or exactly-once invalidation is added.
|
|
195
|
+
|
|
196
|
+
## Existing application and checks
|
|
197
|
+
|
|
198
|
+
`examples/greeting/src/cached.ts` is an opt-in assembly using a bounded memory
|
|
199
|
+
adapter. It caches only the pure formatted text, not input validation or the
|
|
200
|
+
per-call counter. Keys are SHA-256 digests of JSON-encoded names; names whose
|
|
201
|
+
JSON representation exceeds 16 KiB bypass this projection cache to stay within
|
|
202
|
+
its envelope budget. The default greeting
|
|
203
|
+
config and CLI operation remain unchanged.
|
|
204
|
+
|
|
205
|
+
```sh
|
|
206
|
+
bun run --cwd packages/lenso build
|
|
207
|
+
bun run --cwd packages/cache build
|
|
208
|
+
bun run --cwd packages/cache typecheck
|
|
209
|
+
bun run --cwd packages/cache test
|
|
210
|
+
bun examples/greeting/src/cached.ts
|
|
211
|
+
bun test examples/greeting/src/cached.test.ts
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Redis tests launch only a disposable owned loopback server, persistence off,
|
|
215
|
+
using the real Bun driver. They skip explicitly if `redis-server` is unavailable.
|
|
216
|
+
Do not point tests at an existing/production Redis endpoint. Root/memory contain
|
|
217
|
+
no native imports, but Workers deployment/runtime compatibility is unverified;
|
|
218
|
+
Workers can select memory/custom adapters without installing or invoking Redis.
|
|
219
|
+
No additional Workers backend or cache platform is introduced.
|
|
220
|
+
|
|
221
|
+
The shared `bun.lock` includes this workspace and greeting dependency using the
|
|
222
|
+
integration owner's supplied revision. Landing validates it with
|
|
223
|
+
`bun install --frozen-lockfile`; no third-party dependency upgrade is required.
|
|
224
|
+
|
|
225
|
+
### Local validation record
|
|
226
|
+
|
|
227
|
+
- Bun 1.4.2, Redis 8.10.2. Cache build/typecheck passed; 40 cache tests passed,
|
|
228
|
+
including seven tests using the real Redis driver and an owned disposable
|
|
229
|
+
server. The cancellation retention fixture joins/cancels 20,000 callers while
|
|
230
|
+
keeping one loader pending.
|
|
231
|
+
- Greeting build/typecheck passed; six existing/added application tests passed.
|
|
232
|
+
The opt-in cached assembly ran with counts 1 then 2. Default CLI inspect and
|
|
233
|
+
stdin call returned successful JSON.
|
|
234
|
+
- Scoped oxlint, oxfmt checks and `git diff --check` passed. Initial development
|
|
235
|
+
used `--no-save` without changing the root lockfile; landing uses the supplied
|
|
236
|
+
matching lockfile and frozen installation. Builds use existing scripts.
|
|
237
|
+
- Not run: full repository suite, release verification, deployed Workers,
|
|
238
|
+
Redis Cluster/Sentinel/failover or other Redis/server versions.
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type CacheConfig } from "./contracts";
|
|
2
|
+
export declare const MAX_TTL_MS = 86400000;
|
|
3
|
+
export declare function validateName(value: string): void;
|
|
4
|
+
export declare function validateTtl(ttl: number, max: number): void;
|
|
5
|
+
export declare function resolveCacheConfig(config: CacheConfig): Readonly<Required<CacheConfig>>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export type JsonValue = null | boolean | number | string | JsonValue[] | {
|
|
2
|
+
[key: string]: JsonValue;
|
|
3
|
+
};
|
|
4
|
+
export type CacheErrorCode = "invalid-input" | "serialization" | "backend" | "aborted" | "busy" | "closed";
|
|
5
|
+
/** Messages deliberately exclude driver errors, keys, values and credentials. */
|
|
6
|
+
export declare class CacheError extends Error {
|
|
7
|
+
readonly code: CacheErrorCode;
|
|
8
|
+
constructor(code: CacheErrorCode);
|
|
9
|
+
}
|
|
10
|
+
export interface CacheCapabilities {
|
|
11
|
+
readonly provider: string;
|
|
12
|
+
readonly sharing: "process" | "shared";
|
|
13
|
+
readonly invalidation: "namespace-generation";
|
|
14
|
+
readonly batch: "per-key";
|
|
15
|
+
}
|
|
16
|
+
export type RawCacheRead = string | null | CacheError;
|
|
17
|
+
/** Adapter methods receive opaque service-encoded namespaces and keys.
|
|
18
|
+
* Generation tokens must never be reused. Reads and writes fence old generations.
|
|
19
|
+
* Positive finite TTL is mandatory; implementations must not return expired data.
|
|
20
|
+
* Borrowed adapters/drivers are not closed by the service.
|
|
21
|
+
*/
|
|
22
|
+
export interface CacheAdapter {
|
|
23
|
+
readonly capabilities: CacheCapabilities;
|
|
24
|
+
generation(namespace: string): Promise<string>;
|
|
25
|
+
getMany(namespace: string, generation: string, keys: readonly string[]): Promise<RawCacheRead[]>;
|
|
26
|
+
set(namespace: string, generation: string, key: string, value: string, ttlMs: number): Promise<boolean>;
|
|
27
|
+
delete(namespace: string, generation: string, key: string): Promise<void>;
|
|
28
|
+
invalidate(namespace: string): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
export interface CacheConfig {
|
|
31
|
+
readonly namespace: string;
|
|
32
|
+
readonly defaultTtlMs?: number;
|
|
33
|
+
readonly maxTtlMs?: number;
|
|
34
|
+
readonly maxValueBytes?: number;
|
|
35
|
+
readonly maxInFlight?: number;
|
|
36
|
+
readonly failureMode?: "fail-open" | "fail-closed";
|
|
37
|
+
}
|
|
38
|
+
export interface CacheEvent {
|
|
39
|
+
readonly operation: "get" | "set" | "delete" | "invalidate";
|
|
40
|
+
readonly reason: "backend" | "corrupt";
|
|
41
|
+
}
|
|
42
|
+
export interface CacheOptions<T extends JsonValue> extends CacheConfig {
|
|
43
|
+
readonly adapter: CacheAdapter;
|
|
44
|
+
/** Optional runtime type guard. JSON constraints apply even without this guard. */
|
|
45
|
+
readonly validate?: (value: JsonValue) => value is T;
|
|
46
|
+
readonly onEvent?: (event: CacheEvent) => void;
|
|
47
|
+
}
|
|
48
|
+
export type CacheLookup<T> = {
|
|
49
|
+
readonly status: "hit";
|
|
50
|
+
readonly value: T;
|
|
51
|
+
} | {
|
|
52
|
+
readonly status: "miss";
|
|
53
|
+
readonly reason: "absent" | "expired" | "corrupt" | "backend";
|
|
54
|
+
};
|
|
55
|
+
export type CacheBatchResult<T> = CacheLookup<T> | {
|
|
56
|
+
readonly status: "error";
|
|
57
|
+
readonly error: CacheError;
|
|
58
|
+
};
|
|
59
|
+
export type CacheWriteResult = {
|
|
60
|
+
readonly outcome: "stored" | "skipped" | "bypassed" | "superseded";
|
|
61
|
+
};
|
|
62
|
+
export interface Cache<T extends JsonValue = JsonValue> {
|
|
63
|
+
readonly capabilities: CacheCapabilities;
|
|
64
|
+
readonly config: Readonly<Required<CacheConfig>>;
|
|
65
|
+
get(key: string): Promise<CacheLookup<T>>;
|
|
66
|
+
/** Ordered, duplicates preserved, at most 100 keys. No transactional batch guarantee. */
|
|
67
|
+
getMany(keys: readonly string[]): Promise<CacheBatchResult<T>[]>;
|
|
68
|
+
set(key: string, value: T, options?: {
|
|
69
|
+
ttlMs?: number;
|
|
70
|
+
}): Promise<CacheWriteResult>;
|
|
71
|
+
delete(key: string): Promise<{
|
|
72
|
+
outcome: "deleted-or-absent" | "bypassed";
|
|
73
|
+
}>;
|
|
74
|
+
/** Invalidates only this exact scope, not parents, children or other plugins. */
|
|
75
|
+
invalidate(): Promise<{
|
|
76
|
+
outcome: "invalidated" | "bypassed";
|
|
77
|
+
}>;
|
|
78
|
+
scope(name: string): Cache<T>;
|
|
79
|
+
scope<U extends JsonValue>(name: string, options: {
|
|
80
|
+
validate: (value: JsonValue) => value is U;
|
|
81
|
+
}): Cache<U>;
|
|
82
|
+
getOrSet(key: string, loader: (signal: AbortSignal) => Promise<T>, options?: {
|
|
83
|
+
ttlMs?: number;
|
|
84
|
+
signal?: AbortSignal;
|
|
85
|
+
}): Promise<T>;
|
|
86
|
+
/** Closes the service family and aborts its loads, never the borrowed adapter. */
|
|
87
|
+
close(): void;
|
|
88
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// src/contracts.ts
|
|
2
|
+
class CacheError2 extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code) {
|
|
5
|
+
super(`Cache operation failed (${code})`);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.name = "CacheError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// src/config.ts
|
|
12
|
+
var MAX_TTL_MS = 86400000;
|
|
13
|
+
function validateName(value) {
|
|
14
|
+
if (typeof value !== "string" || !value || new TextEncoder().encode(value).length > 256 || [...value].some((part) => {
|
|
15
|
+
const code = part.charCodeAt(0);
|
|
16
|
+
return code < 32 || code >= 127 && code <= 159 || part.length === 1 && code >= 55296 && code <= 57343;
|
|
17
|
+
})) {
|
|
18
|
+
throw new CacheError2("invalid-input");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function validateTtl(ttl, max) {
|
|
22
|
+
if (!Number.isSafeInteger(ttl) || ttl < 0 || ttl > max)
|
|
23
|
+
throw new CacheError2("invalid-input");
|
|
24
|
+
}
|
|
25
|
+
function resolveCacheConfig2(config) {
|
|
26
|
+
validateName(config.namespace);
|
|
27
|
+
for (const key of ["defaultTtlMs", "maxTtlMs", "maxValueBytes", "maxInFlight"]) {
|
|
28
|
+
if (config[key] !== undefined && typeof config[key] !== "number")
|
|
29
|
+
throw new CacheError2("invalid-input");
|
|
30
|
+
}
|
|
31
|
+
if (config.failureMode !== undefined && config.failureMode !== "fail-open" && config.failureMode !== "fail-closed")
|
|
32
|
+
throw new CacheError2("invalid-input");
|
|
33
|
+
const resolved = {
|
|
34
|
+
namespace: config.namespace,
|
|
35
|
+
defaultTtlMs: config.defaultTtlMs ?? 60000,
|
|
36
|
+
maxTtlMs: config.maxTtlMs ?? MAX_TTL_MS,
|
|
37
|
+
maxValueBytes: config.maxValueBytes ?? 65536,
|
|
38
|
+
maxInFlight: config.maxInFlight ?? 128,
|
|
39
|
+
failureMode: config.failureMode ?? "fail-closed"
|
|
40
|
+
};
|
|
41
|
+
validateTtl(resolved.maxTtlMs, MAX_TTL_MS);
|
|
42
|
+
if (resolved.maxTtlMs === 0)
|
|
43
|
+
throw new CacheError2("invalid-input");
|
|
44
|
+
validateTtl(resolved.defaultTtlMs, resolved.maxTtlMs);
|
|
45
|
+
for (const [value, max] of [
|
|
46
|
+
[resolved.maxValueBytes, 1048576],
|
|
47
|
+
[resolved.maxInFlight, 1e4]
|
|
48
|
+
]) {
|
|
49
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > max)
|
|
50
|
+
throw new CacheError2("invalid-input");
|
|
51
|
+
}
|
|
52
|
+
if (resolved.failureMode !== "fail-open" && resolved.failureMode !== "fail-closed")
|
|
53
|
+
throw new CacheError2("invalid-input");
|
|
54
|
+
return Object.freeze(resolved);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export { CacheError2, MAX_TTL_MS, validateName, validateTtl, resolveCacheConfig2 };
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CacheError2,
|
|
3
|
+
validateName,
|
|
4
|
+
validateTtl,
|
|
5
|
+
resolveCacheConfig2
|
|
6
|
+
} from "./index-gp63f5x9.js";
|
|
7
|
+
|
|
8
|
+
// src/json.ts
|
|
9
|
+
var encoder = new TextEncoder;
|
|
10
|
+
function check(value, ancestors = new Set, depth = 0) {
|
|
11
|
+
if (value === null || typeof value === "boolean" || typeof value === "string")
|
|
12
|
+
return;
|
|
13
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
14
|
+
return;
|
|
15
|
+
if (typeof value !== "object" || value === null || depth >= 64 || ancestors.has(value))
|
|
16
|
+
throw new CacheError2("serialization");
|
|
17
|
+
const array = Array.isArray(value);
|
|
18
|
+
if (array && Object.getPrototypeOf(value) !== Array.prototype)
|
|
19
|
+
throw new CacheError2("serialization");
|
|
20
|
+
if (!array && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
|
|
21
|
+
throw new CacheError2("serialization");
|
|
22
|
+
ancestors.add(value);
|
|
23
|
+
try {
|
|
24
|
+
let count = 0;
|
|
25
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
26
|
+
if (array && key === "length")
|
|
27
|
+
continue;
|
|
28
|
+
if (typeof key !== "string")
|
|
29
|
+
throw new CacheError2("serialization");
|
|
30
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
31
|
+
if (!descriptor.enumerable || !("value" in descriptor))
|
|
32
|
+
throw new CacheError2("serialization");
|
|
33
|
+
if (array && (!/^(0|[1-9]\d*)$/.test(key) || Number(key) >= value.length))
|
|
34
|
+
throw new CacheError2("serialization");
|
|
35
|
+
check(descriptor.value, ancestors, depth + 1);
|
|
36
|
+
count++;
|
|
37
|
+
}
|
|
38
|
+
if (array && count !== value.length)
|
|
39
|
+
throw new CacheError2("serialization");
|
|
40
|
+
} finally {
|
|
41
|
+
ancestors.delete(value);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function encode(value, expiresAt, maxBytes, validate) {
|
|
45
|
+
try {
|
|
46
|
+
check(value);
|
|
47
|
+
if (validate && !validate(value))
|
|
48
|
+
throw new CacheError2("serialization");
|
|
49
|
+
const text = JSON.stringify({ v: 1, expiresAt, value });
|
|
50
|
+
if (encoder.encode(text).length > maxBytes)
|
|
51
|
+
throw new CacheError2("serialization");
|
|
52
|
+
return text;
|
|
53
|
+
} catch {
|
|
54
|
+
throw new CacheError2("serialization");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function decode(text, maxBytes, validate) {
|
|
58
|
+
if (encoder.encode(text).length > maxBytes)
|
|
59
|
+
throw new CacheError2("serialization");
|
|
60
|
+
const envelope = JSON.parse(text);
|
|
61
|
+
if (envelope === null || typeof envelope !== "object" || envelope.v !== 1 || !Number.isSafeInteger(envelope.expiresAt) || envelope.expiresAt < 0 || !Object.hasOwn(envelope, "value") || Object.keys(envelope).length !== 3)
|
|
62
|
+
throw new CacheError2("serialization");
|
|
63
|
+
check(envelope.value);
|
|
64
|
+
if (validate && !validate(envelope.value))
|
|
65
|
+
throw new CacheError2("serialization");
|
|
66
|
+
return { expiresAt: envelope.expiresAt, value: envelope.value };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// src/index.ts
|
|
70
|
+
function createCache2(options) {
|
|
71
|
+
const config = resolveCacheConfig2(options);
|
|
72
|
+
const flights = new Map;
|
|
73
|
+
const active = new Set;
|
|
74
|
+
let closed = false;
|
|
75
|
+
const adapter = options.adapter;
|
|
76
|
+
function ready() {
|
|
77
|
+
if (closed)
|
|
78
|
+
throw new CacheError2("closed");
|
|
79
|
+
}
|
|
80
|
+
function event(operation, reason) {
|
|
81
|
+
try {
|
|
82
|
+
options.onEvent?.({ operation, reason });
|
|
83
|
+
} catch {}
|
|
84
|
+
}
|
|
85
|
+
function backend(operation) {
|
|
86
|
+
event(operation, "backend");
|
|
87
|
+
if (config.failureMode === "fail-closed")
|
|
88
|
+
throw new CacheError2("backend");
|
|
89
|
+
}
|
|
90
|
+
function fence(namespace, key) {
|
|
91
|
+
const prefix = `${namespace}
|
|
92
|
+
`;
|
|
93
|
+
for (const [id, flight] of flights) {
|
|
94
|
+
if (key === undefined ? id.startsWith(prefix) : id === `${prefix}${key}`) {
|
|
95
|
+
flight.writable = false;
|
|
96
|
+
flights.delete(id);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function scoped(path, validate) {
|
|
101
|
+
const namespace = JSON.stringify(path);
|
|
102
|
+
async function read(keys) {
|
|
103
|
+
let generation;
|
|
104
|
+
let raw;
|
|
105
|
+
try {
|
|
106
|
+
generation = await adapter.generation(namespace);
|
|
107
|
+
raw = await adapter.getMany(namespace, generation, keys);
|
|
108
|
+
if (raw.length !== keys.length)
|
|
109
|
+
throw new CacheError2("backend");
|
|
110
|
+
} catch {
|
|
111
|
+
event("get", "backend");
|
|
112
|
+
return {
|
|
113
|
+
results: keys.map(() => config.failureMode === "fail-open" ? { status: "miss", reason: "backend" } : { status: "error", error: new CacheError2("backend") })
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
generation,
|
|
118
|
+
results: raw.map((item) => {
|
|
119
|
+
if (item instanceof CacheError2) {
|
|
120
|
+
event("get", "backend");
|
|
121
|
+
return config.failureMode === "fail-open" ? { status: "miss", reason: "backend" } : { status: "error", error: new CacheError2("backend") };
|
|
122
|
+
}
|
|
123
|
+
if (item === null)
|
|
124
|
+
return { status: "miss", reason: "absent" };
|
|
125
|
+
try {
|
|
126
|
+
const entry = decode(item, config.maxValueBytes, validate);
|
|
127
|
+
if (entry.expiresAt <= Date.now())
|
|
128
|
+
return { status: "miss", reason: "expired" };
|
|
129
|
+
return { status: "hit", value: entry.value };
|
|
130
|
+
} catch {
|
|
131
|
+
event("get", "corrupt");
|
|
132
|
+
return { status: "miss", reason: "corrupt" };
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function unwrap(result) {
|
|
138
|
+
if (result.status === "error")
|
|
139
|
+
throw result.error;
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
function ttl(value) {
|
|
143
|
+
const result = value === undefined ? config.defaultTtlMs : value;
|
|
144
|
+
validateTtl(result, config.maxTtlMs);
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
async function write(key, text, duration, generation) {
|
|
148
|
+
if (generation === undefined)
|
|
149
|
+
return { outcome: "bypassed" };
|
|
150
|
+
try {
|
|
151
|
+
const stored = await adapter.set(namespace, generation, key, text, duration);
|
|
152
|
+
return { outcome: stored ? "stored" : "superseded" };
|
|
153
|
+
} catch {
|
|
154
|
+
backend("set");
|
|
155
|
+
return { outcome: "bypassed" };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const service = {
|
|
159
|
+
capabilities: adapter.capabilities,
|
|
160
|
+
config,
|
|
161
|
+
async get(key) {
|
|
162
|
+
ready();
|
|
163
|
+
validateName(key);
|
|
164
|
+
return unwrap((await read([key])).results[0]);
|
|
165
|
+
},
|
|
166
|
+
async getMany(keys) {
|
|
167
|
+
ready();
|
|
168
|
+
if (!Array.isArray(keys) || keys.length > 100)
|
|
169
|
+
throw new CacheError2("invalid-input");
|
|
170
|
+
const copied = [...keys];
|
|
171
|
+
copied.forEach(validateName);
|
|
172
|
+
if (!copied.length)
|
|
173
|
+
return [];
|
|
174
|
+
return (await read(copied)).results;
|
|
175
|
+
},
|
|
176
|
+
async set(key, value, input = {}) {
|
|
177
|
+
ready();
|
|
178
|
+
validateName(key);
|
|
179
|
+
const duration = ttl(input.ttlMs);
|
|
180
|
+
const text = encode(value, Date.now() + duration, config.maxValueBytes, validate);
|
|
181
|
+
fence(namespace, key);
|
|
182
|
+
if (duration === 0) {
|
|
183
|
+
const result = await service.delete(key);
|
|
184
|
+
return { outcome: result.outcome === "bypassed" ? "bypassed" : "skipped" };
|
|
185
|
+
}
|
|
186
|
+
let generation;
|
|
187
|
+
try {
|
|
188
|
+
generation = await adapter.generation(namespace);
|
|
189
|
+
} catch {
|
|
190
|
+
backend("set");
|
|
191
|
+
return { outcome: "bypassed" };
|
|
192
|
+
}
|
|
193
|
+
return write(key, text, duration, generation);
|
|
194
|
+
},
|
|
195
|
+
async delete(key) {
|
|
196
|
+
ready();
|
|
197
|
+
validateName(key);
|
|
198
|
+
fence(namespace, key);
|
|
199
|
+
try {
|
|
200
|
+
const generation = await adapter.generation(namespace);
|
|
201
|
+
await adapter.delete(namespace, generation, key);
|
|
202
|
+
return { outcome: "deleted-or-absent" };
|
|
203
|
+
} catch {
|
|
204
|
+
backend("delete");
|
|
205
|
+
return { outcome: "bypassed" };
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
async invalidate() {
|
|
209
|
+
ready();
|
|
210
|
+
fence(namespace);
|
|
211
|
+
try {
|
|
212
|
+
await adapter.invalidate(namespace);
|
|
213
|
+
return { outcome: "invalidated" };
|
|
214
|
+
} catch {
|
|
215
|
+
backend("invalidate");
|
|
216
|
+
return { outcome: "bypassed" };
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
scope(name, input) {
|
|
220
|
+
ready();
|
|
221
|
+
validateName(name);
|
|
222
|
+
if (path.length >= 16)
|
|
223
|
+
throw new CacheError2("invalid-input");
|
|
224
|
+
return scoped([...path, name], input?.validate ?? validate);
|
|
225
|
+
},
|
|
226
|
+
async getOrSet(key, loader, input = {}) {
|
|
227
|
+
ready();
|
|
228
|
+
validateName(key);
|
|
229
|
+
const duration = ttl(input.ttlMs);
|
|
230
|
+
if (input.signal?.aborted)
|
|
231
|
+
throw new CacheError2("aborted");
|
|
232
|
+
const id = `${namespace}
|
|
233
|
+
${key}`;
|
|
234
|
+
let flight = flights.get(id);
|
|
235
|
+
if (!flight) {
|
|
236
|
+
if (active.size >= config.maxInFlight)
|
|
237
|
+
throw new CacheError2("busy");
|
|
238
|
+
flight = {
|
|
239
|
+
controller: new AbortController,
|
|
240
|
+
waiters: new Set,
|
|
241
|
+
writable: true
|
|
242
|
+
};
|
|
243
|
+
const current = flight;
|
|
244
|
+
active.add(current);
|
|
245
|
+
flights.set(id, current);
|
|
246
|
+
const work = (async () => {
|
|
247
|
+
const snapshot = await read([key]);
|
|
248
|
+
const lookup = unwrap(snapshot.results[0]);
|
|
249
|
+
if (duration > 0 && lookup.status === "hit")
|
|
250
|
+
return encode(lookup.value, Date.now() + duration, config.maxValueBytes, validate);
|
|
251
|
+
if (duration === 0 && snapshot.generation !== undefined && current.writable && !closed && !current.controller.signal.aborted) {
|
|
252
|
+
try {
|
|
253
|
+
await adapter.delete(namespace, snapshot.generation, key);
|
|
254
|
+
} catch {
|
|
255
|
+
backend("delete");
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (current.controller.signal.aborted)
|
|
259
|
+
throw new CacheError2("aborted");
|
|
260
|
+
const value = await loader(current.controller.signal);
|
|
261
|
+
if (current.controller.signal.aborted)
|
|
262
|
+
throw new CacheError2("aborted");
|
|
263
|
+
const text = encode(value, Date.now() + duration, config.maxValueBytes, validate);
|
|
264
|
+
if (duration > 0 && current.writable && !closed)
|
|
265
|
+
await write(key, text, duration, snapshot.generation);
|
|
266
|
+
return text;
|
|
267
|
+
})();
|
|
268
|
+
const settle = (failed, value) => {
|
|
269
|
+
active.delete(current);
|
|
270
|
+
if (flights.get(id) === current)
|
|
271
|
+
flights.delete(id);
|
|
272
|
+
for (const waiter of current.waiters)
|
|
273
|
+
waiter.finish(failed, value);
|
|
274
|
+
};
|
|
275
|
+
work.then((value) => settle(false, value), (error) => settle(true, error));
|
|
276
|
+
}
|
|
277
|
+
const current = flight;
|
|
278
|
+
const text = await new Promise((resolve, reject) => {
|
|
279
|
+
let settled = false;
|
|
280
|
+
const finish = (failed, value) => {
|
|
281
|
+
if (settled)
|
|
282
|
+
return;
|
|
283
|
+
settled = true;
|
|
284
|
+
input.signal?.removeEventListener("abort", abort);
|
|
285
|
+
current.controller.signal.removeEventListener("abort", abort);
|
|
286
|
+
current.waiters.delete(waiter);
|
|
287
|
+
if (failed)
|
|
288
|
+
reject(value);
|
|
289
|
+
else
|
|
290
|
+
resolve(value);
|
|
291
|
+
};
|
|
292
|
+
const waiter = { finish };
|
|
293
|
+
const abort = () => {
|
|
294
|
+
finish(true, new CacheError2("aborted"));
|
|
295
|
+
if (current.waiters.size === 0) {
|
|
296
|
+
current.writable = false;
|
|
297
|
+
if (flights.get(id) === current)
|
|
298
|
+
flights.delete(id);
|
|
299
|
+
current.controller.abort();
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
current.waiters.add(waiter);
|
|
303
|
+
input.signal?.addEventListener("abort", abort, { once: true });
|
|
304
|
+
current.controller.signal.addEventListener("abort", abort, { once: true });
|
|
305
|
+
if (input.signal?.aborted || current.controller.signal.aborted)
|
|
306
|
+
abort();
|
|
307
|
+
});
|
|
308
|
+
return decode(text, config.maxValueBytes, validate).value;
|
|
309
|
+
},
|
|
310
|
+
close() {
|
|
311
|
+
if (closed)
|
|
312
|
+
return;
|
|
313
|
+
closed = true;
|
|
314
|
+
flights.clear();
|
|
315
|
+
for (const flight of active) {
|
|
316
|
+
flight.writable = false;
|
|
317
|
+
flight.controller.abort();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
return service;
|
|
322
|
+
}
|
|
323
|
+
return scoped([config.namespace], options.validate);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export { createCache2 };
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCache2
|
|
3
|
+
} from "./index-pmwehpb3.js";
|
|
4
|
+
import {
|
|
5
|
+
CacheError2,
|
|
6
|
+
resolveCacheConfig2
|
|
7
|
+
} from "./index-gp63f5x9.js";
|
|
8
|
+
export {
|
|
9
|
+
CacheError2 as CacheError,
|
|
10
|
+
createCache2 as createCache,
|
|
11
|
+
resolveCacheConfig2 as resolveCacheConfig
|
|
12
|
+
};
|
package/dist/json.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type JsonValue } from "./contracts";
|
|
2
|
+
export declare function encode(value: JsonValue, expiresAt: number, maxBytes: number, validate?: (value: JsonValue) => boolean): string;
|
|
3
|
+
export declare function decode<T extends JsonValue>(text: string, maxBytes: number, validate?: (value: JsonValue) => value is T): {
|
|
4
|
+
expiresAt: number;
|
|
5
|
+
value: T;
|
|
6
|
+
};
|
package/dist/memory.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type CacheAdapter } from "./contracts";
|
|
2
|
+
export interface MemoryCacheAdapterOptions {
|
|
3
|
+
maxEntries?: number;
|
|
4
|
+
maxBytes?: number;
|
|
5
|
+
maxNamespaces?: number;
|
|
6
|
+
now?: () => number;
|
|
7
|
+
}
|
|
8
|
+
export declare function createMemoryCacheAdapter(options?: MemoryCacheAdapterOptions): CacheAdapter;
|
package/dist/memory.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CacheError2,
|
|
3
|
+
MAX_TTL_MS,
|
|
4
|
+
validateTtl
|
|
5
|
+
} from "./index-gp63f5x9.js";
|
|
6
|
+
|
|
7
|
+
// src/memory.ts
|
|
8
|
+
var DEFAULT_MAX_ENTRIES = 1000;
|
|
9
|
+
var DEFAULT_MAX_BYTES = 8 * 1024 * 1024;
|
|
10
|
+
var DEFAULT_MAX_NAMESPACES = 128;
|
|
11
|
+
var MAX_BATCH_SIZE = 100;
|
|
12
|
+
var encoder = new TextEncoder;
|
|
13
|
+
function validateLimit(value, fallback) {
|
|
14
|
+
const limit = value ?? fallback;
|
|
15
|
+
if (!Number.isSafeInteger(limit) || limit <= 0)
|
|
16
|
+
throw new CacheError2("invalid-input");
|
|
17
|
+
return limit;
|
|
18
|
+
}
|
|
19
|
+
function freshGeneration() {
|
|
20
|
+
return crypto.randomUUID();
|
|
21
|
+
}
|
|
22
|
+
function createMemoryCacheAdapter(options = {}) {
|
|
23
|
+
const maxEntries = validateLimit(options.maxEntries, DEFAULT_MAX_ENTRIES);
|
|
24
|
+
const maxBytes = validateLimit(options.maxBytes, DEFAULT_MAX_BYTES);
|
|
25
|
+
const maxNamespaces = validateLimit(options.maxNamespaces, DEFAULT_MAX_NAMESPACES);
|
|
26
|
+
const now = options.now ?? Date.now;
|
|
27
|
+
const namespaces = new Map;
|
|
28
|
+
const entries = new Map;
|
|
29
|
+
let bytes = 0;
|
|
30
|
+
const capabilities = Object.freeze({
|
|
31
|
+
provider: "memory",
|
|
32
|
+
sharing: "process",
|
|
33
|
+
invalidation: "namespace-generation",
|
|
34
|
+
batch: "per-key"
|
|
35
|
+
});
|
|
36
|
+
function composite(namespace, key) {
|
|
37
|
+
return JSON.stringify([namespace, key]);
|
|
38
|
+
}
|
|
39
|
+
function removeEntry(id) {
|
|
40
|
+
const entry = entries.get(id);
|
|
41
|
+
if (!entry)
|
|
42
|
+
return;
|
|
43
|
+
entries.delete(id);
|
|
44
|
+
bytes -= entry.bytes;
|
|
45
|
+
}
|
|
46
|
+
function evictNamespace(namespace) {
|
|
47
|
+
const prefix = `[${JSON.stringify(namespace)},`;
|
|
48
|
+
for (const [id] of entries) {
|
|
49
|
+
if (id.startsWith(prefix))
|
|
50
|
+
removeEntry(id);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function touchNamespace(namespace) {
|
|
54
|
+
let generation = namespaces.get(namespace);
|
|
55
|
+
if (generation !== undefined) {
|
|
56
|
+
namespaces.delete(namespace);
|
|
57
|
+
namespaces.set(namespace, generation);
|
|
58
|
+
return generation;
|
|
59
|
+
}
|
|
60
|
+
if (namespaces.size >= maxNamespaces) {
|
|
61
|
+
const oldest = namespaces.keys().next().value;
|
|
62
|
+
namespaces.delete(oldest);
|
|
63
|
+
evictNamespace(oldest);
|
|
64
|
+
}
|
|
65
|
+
generation = freshGeneration();
|
|
66
|
+
namespaces.set(namespace, generation);
|
|
67
|
+
return generation;
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
capabilities,
|
|
71
|
+
async generation(namespace) {
|
|
72
|
+
return touchNamespace(namespace);
|
|
73
|
+
},
|
|
74
|
+
async getMany(namespace, generation, keys) {
|
|
75
|
+
if (keys.length > MAX_BATCH_SIZE)
|
|
76
|
+
throw new CacheError2("invalid-input");
|
|
77
|
+
const current = namespaces.get(namespace);
|
|
78
|
+
if (current !== generation)
|
|
79
|
+
return keys.map(() => null);
|
|
80
|
+
touchNamespace(namespace);
|
|
81
|
+
return keys.map((key) => {
|
|
82
|
+
const id = composite(namespace, key);
|
|
83
|
+
const entry = entries.get(id);
|
|
84
|
+
if (!entry || entry.generation !== generation)
|
|
85
|
+
return null;
|
|
86
|
+
if (entry.expiresAt <= now()) {
|
|
87
|
+
removeEntry(id);
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
entries.delete(id);
|
|
91
|
+
entries.set(id, entry);
|
|
92
|
+
return entry.value;
|
|
93
|
+
});
|
|
94
|
+
},
|
|
95
|
+
async set(namespace, generation, key, value, ttlMs) {
|
|
96
|
+
validateTtl(ttlMs, MAX_TTL_MS);
|
|
97
|
+
if (ttlMs === 0)
|
|
98
|
+
throw new CacheError2("invalid-input");
|
|
99
|
+
if (namespaces.get(namespace) !== generation)
|
|
100
|
+
return false;
|
|
101
|
+
touchNamespace(namespace);
|
|
102
|
+
const id = composite(namespace, key);
|
|
103
|
+
const size = encoder.encode(namespace).byteLength + encoder.encode(generation).byteLength + encoder.encode(key).byteLength + encoder.encode(value).byteLength;
|
|
104
|
+
if (size > maxBytes)
|
|
105
|
+
throw new CacheError2("serialization");
|
|
106
|
+
removeEntry(id);
|
|
107
|
+
while (entries.size >= maxEntries || bytes + size > maxBytes) {
|
|
108
|
+
const oldest = entries.keys().next().value;
|
|
109
|
+
if (oldest === undefined)
|
|
110
|
+
break;
|
|
111
|
+
removeEntry(oldest);
|
|
112
|
+
}
|
|
113
|
+
entries.set(id, { generation, value, expiresAt: now() + ttlMs, bytes: size });
|
|
114
|
+
bytes += size;
|
|
115
|
+
return true;
|
|
116
|
+
},
|
|
117
|
+
async delete(namespace, generation, key) {
|
|
118
|
+
if (namespaces.get(namespace) !== generation)
|
|
119
|
+
return;
|
|
120
|
+
touchNamespace(namespace);
|
|
121
|
+
removeEntry(composite(namespace, key));
|
|
122
|
+
},
|
|
123
|
+
async invalidate(namespace) {
|
|
124
|
+
if (namespaces.has(namespace))
|
|
125
|
+
evictNamespace(namespace);
|
|
126
|
+
namespaces.delete(namespace);
|
|
127
|
+
touchNamespace(namespace);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
export {
|
|
132
|
+
createMemoryCacheAdapter
|
|
133
|
+
};
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Plugin } from "@lenso/core/plugin";
|
|
2
|
+
import { type ConfigSource } from "@lenso/core/config";
|
|
3
|
+
import { type Cache, type CacheAdapter, type CacheConfig, type JsonValue } from "./index";
|
|
4
|
+
export declare const cacheConfig: import("@lenso/core/config").ConfigContract<{
|
|
5
|
+
"~standard": {
|
|
6
|
+
version: 1;
|
|
7
|
+
vendor: string;
|
|
8
|
+
types: {
|
|
9
|
+
input: CacheConfig;
|
|
10
|
+
output: Readonly<Required<CacheConfig>>;
|
|
11
|
+
} | undefined;
|
|
12
|
+
validate(input: unknown): {
|
|
13
|
+
issues: {
|
|
14
|
+
message: string;
|
|
15
|
+
}[];
|
|
16
|
+
value?: undefined;
|
|
17
|
+
} | {
|
|
18
|
+
issues?: undefined;
|
|
19
|
+
value: Readonly<Required<CacheConfig>>;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
}>;
|
|
23
|
+
export declare function createCachePlugin<T extends JsonValue = JsonValue>(options: {
|
|
24
|
+
id: string;
|
|
25
|
+
adapter: Plugin<CacheAdapter>;
|
|
26
|
+
config: CacheConfig | readonly ConfigSource[];
|
|
27
|
+
validate?: (value: JsonValue) => value is T;
|
|
28
|
+
}): Plugin<Cache<T>>;
|
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCache2
|
|
3
|
+
} from "./index-pmwehpb3.js";
|
|
4
|
+
import {
|
|
5
|
+
resolveCacheConfig2
|
|
6
|
+
} from "./index-gp63f5x9.js";
|
|
7
|
+
|
|
8
|
+
// src/plugin.ts
|
|
9
|
+
import { bindConfig, definePluginConfig } from "@lenso/core/config";
|
|
10
|
+
var schema = {
|
|
11
|
+
"~standard": {
|
|
12
|
+
version: 1,
|
|
13
|
+
vendor: "lenso-cache",
|
|
14
|
+
types: undefined,
|
|
15
|
+
validate(input) {
|
|
16
|
+
try {
|
|
17
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
18
|
+
return { issues: [{ message: "Invalid cache configuration" }] };
|
|
19
|
+
const allowed = new Set([
|
|
20
|
+
"namespace",
|
|
21
|
+
"defaultTtlMs",
|
|
22
|
+
"maxTtlMs",
|
|
23
|
+
"maxValueBytes",
|
|
24
|
+
"maxInFlight",
|
|
25
|
+
"failureMode"
|
|
26
|
+
]);
|
|
27
|
+
if (Object.keys(input).some((key) => !allowed.has(key)))
|
|
28
|
+
return { issues: [{ message: "Unknown cache configuration field" }] };
|
|
29
|
+
return { value: resolveCacheConfig2(input) };
|
|
30
|
+
} catch {
|
|
31
|
+
return { issues: [{ message: "Invalid cache configuration" }] };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
var cacheConfig = definePluginConfig({
|
|
37
|
+
schema,
|
|
38
|
+
description: "Finite-TTL JSON cache. No authorization decision caching or management exposure.",
|
|
39
|
+
fields: [
|
|
40
|
+
{ path: ["namespace"], description: "Plugin-owned namespace, not an authorization boundary." },
|
|
41
|
+
{ path: ["defaultTtlMs"], description: "Default TTL in milliseconds. Zero disables storage." },
|
|
42
|
+
{
|
|
43
|
+
path: ["failureMode"],
|
|
44
|
+
description: "Fail closed by default; fail open explicitly bypasses the backend."
|
|
45
|
+
}
|
|
46
|
+
],
|
|
47
|
+
jsonSchema: () => ({
|
|
48
|
+
type: "object",
|
|
49
|
+
additionalProperties: false,
|
|
50
|
+
required: ["namespace"],
|
|
51
|
+
properties: {
|
|
52
|
+
namespace: { type: "string", minLength: 1, maxLength: 256 },
|
|
53
|
+
defaultTtlMs: { type: "integer", minimum: 0, maximum: 86400000 },
|
|
54
|
+
maxTtlMs: { type: "integer", minimum: 1, maximum: 86400000 },
|
|
55
|
+
maxValueBytes: { type: "integer", minimum: 1, maximum: 1048576 },
|
|
56
|
+
maxInFlight: { type: "integer", minimum: 1, maximum: 1e4 },
|
|
57
|
+
failureMode: { enum: ["fail-open", "fail-closed"] }
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
});
|
|
61
|
+
function createCachePlugin(options) {
|
|
62
|
+
return bindConfig(cacheConfig, options.config, {
|
|
63
|
+
id: options.id,
|
|
64
|
+
requires: [options.adapter],
|
|
65
|
+
setup(context, config) {
|
|
66
|
+
const cache = createCache2({
|
|
67
|
+
...config,
|
|
68
|
+
adapter: context.get(options.adapter),
|
|
69
|
+
validate: options.validate,
|
|
70
|
+
onEvent(event) {
|
|
71
|
+
context.logger?.warn({ operation: event.operation, reason: event.reason }, "Cache backend bypass or corrupt entry");
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
context.onCleanup(() => cache.close());
|
|
75
|
+
return cache;
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
export {
|
|
80
|
+
cacheConfig,
|
|
81
|
+
createCachePlugin
|
|
82
|
+
};
|
package/dist/redis.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { RedisClient } from "bun";
|
|
2
|
+
import { type CacheAdapter } from "./contracts";
|
|
3
|
+
export interface RedisCacheAdapterOptions {
|
|
4
|
+
readonly client: RedisClient;
|
|
5
|
+
readonly prefix?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function createRedisCacheAdapter({ client, prefix, }: RedisCacheAdapterOptions): CacheAdapter;
|
package/dist/redis.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CacheError2,
|
|
3
|
+
MAX_TTL_MS,
|
|
4
|
+
validateTtl
|
|
5
|
+
} from "./index-gp63f5x9.js";
|
|
6
|
+
|
|
7
|
+
// src/redis.ts
|
|
8
|
+
var DEFAULT_PREFIX = "lenso-cache";
|
|
9
|
+
var MAX_BATCH_SIZE = 100;
|
|
10
|
+
var MAX_PREFIX_LENGTH = 256;
|
|
11
|
+
var SAFE_PREFIX = /^[A-Za-z0-9:_-]+$/;
|
|
12
|
+
var GENERATION_SCRIPT = `
|
|
13
|
+
local generation = redis.call("GET", KEYS[1])
|
|
14
|
+
if not generation then
|
|
15
|
+
redis.call("SET", KEYS[1], ARGV[1], "NX")
|
|
16
|
+
generation = redis.call("GET", KEYS[1])
|
|
17
|
+
end
|
|
18
|
+
return generation
|
|
19
|
+
`;
|
|
20
|
+
var READ_SCRIPT = `
|
|
21
|
+
local generation = redis.call("GET", KEYS[1])
|
|
22
|
+
if generation ~= ARGV[1] then
|
|
23
|
+
return cjson.encode({false})
|
|
24
|
+
end
|
|
25
|
+
local result = {true}
|
|
26
|
+
for i = 2, #KEYS do
|
|
27
|
+
local value = redis.pcall("GET", KEYS[i])
|
|
28
|
+
if type(value) == "table" and value.err then
|
|
29
|
+
result[#result + 1] = {error = true}
|
|
30
|
+
else
|
|
31
|
+
result[#result + 1] = value or cjson.null
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
return cjson.encode(result)
|
|
35
|
+
`;
|
|
36
|
+
var SET_SCRIPT = `
|
|
37
|
+
if redis.call("GET", KEYS[1]) ~= ARGV[1] then return 0 end
|
|
38
|
+
redis.call("SET", KEYS[2], ARGV[2], "PX", ARGV[3])
|
|
39
|
+
return 1
|
|
40
|
+
`;
|
|
41
|
+
var DELETE_SCRIPT = `
|
|
42
|
+
if redis.call("GET", KEYS[1]) ~= ARGV[1] then return 0 end
|
|
43
|
+
redis.call("DEL", KEYS[2])
|
|
44
|
+
return 1
|
|
45
|
+
`;
|
|
46
|
+
var INVALIDATE_SCRIPT = `
|
|
47
|
+
redis.call("SET", KEYS[1], ARGV[1])
|
|
48
|
+
return 1
|
|
49
|
+
`;
|
|
50
|
+
function encode(part) {
|
|
51
|
+
return Buffer.from(part, "utf8").toString("hex");
|
|
52
|
+
}
|
|
53
|
+
function scopeKey(prefix, namespace) {
|
|
54
|
+
const encoded = encode(namespace);
|
|
55
|
+
return `${prefix}:scope:${encoded.length}:${encoded}`;
|
|
56
|
+
}
|
|
57
|
+
function dataKey(prefix, namespace, generation, key) {
|
|
58
|
+
const scope = encode(namespace);
|
|
59
|
+
const epoch = encode(generation);
|
|
60
|
+
const item = encode(key);
|
|
61
|
+
return `${prefix}:data:${scope.length}:${scope}:${epoch.length}:${epoch}:${item.length}:${item}`;
|
|
62
|
+
}
|
|
63
|
+
function uuid() {
|
|
64
|
+
return crypto.randomUUID();
|
|
65
|
+
}
|
|
66
|
+
function backendError() {
|
|
67
|
+
return new CacheError2("backend");
|
|
68
|
+
}
|
|
69
|
+
function createRedisCacheAdapter({
|
|
70
|
+
client,
|
|
71
|
+
prefix = DEFAULT_PREFIX
|
|
72
|
+
}) {
|
|
73
|
+
if (typeof prefix !== "string" || prefix.length === 0 || prefix.length > MAX_PREFIX_LENGTH || !SAFE_PREFIX.test(prefix)) {
|
|
74
|
+
throw new CacheError2("invalid-input");
|
|
75
|
+
}
|
|
76
|
+
async function evalScript(script, keys, args) {
|
|
77
|
+
try {
|
|
78
|
+
return await client.send("EVAL", [script, String(keys.length), ...keys, ...args]);
|
|
79
|
+
} catch {
|
|
80
|
+
throw backendError();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
capabilities: {
|
|
85
|
+
provider: "redis",
|
|
86
|
+
sharing: "shared",
|
|
87
|
+
invalidation: "namespace-generation",
|
|
88
|
+
batch: "per-key"
|
|
89
|
+
},
|
|
90
|
+
async generation(namespace) {
|
|
91
|
+
const value = await evalScript(GENERATION_SCRIPT, [scopeKey(prefix, namespace)], [uuid()]);
|
|
92
|
+
if (typeof value !== "string")
|
|
93
|
+
throw backendError();
|
|
94
|
+
return value;
|
|
95
|
+
},
|
|
96
|
+
async getMany(namespace, generation, keys) {
|
|
97
|
+
if (keys.length > MAX_BATCH_SIZE)
|
|
98
|
+
throw new CacheError2("invalid-input");
|
|
99
|
+
if (keys.length === 0)
|
|
100
|
+
return [];
|
|
101
|
+
const result = await evalScript(READ_SCRIPT, [
|
|
102
|
+
scopeKey(prefix, namespace),
|
|
103
|
+
...keys.map((key) => dataKey(prefix, namespace, generation, key))
|
|
104
|
+
], [generation]);
|
|
105
|
+
if (typeof result !== "string")
|
|
106
|
+
throw backendError();
|
|
107
|
+
let values;
|
|
108
|
+
try {
|
|
109
|
+
values = JSON.parse(result);
|
|
110
|
+
} catch {
|
|
111
|
+
throw backendError();
|
|
112
|
+
}
|
|
113
|
+
if (!Array.isArray(values))
|
|
114
|
+
throw backendError();
|
|
115
|
+
if (values[0] !== true) {
|
|
116
|
+
return keys.map(() => null);
|
|
117
|
+
}
|
|
118
|
+
if (values.length !== keys.length + 1)
|
|
119
|
+
throw backendError();
|
|
120
|
+
return values.slice(1).map((value) => {
|
|
121
|
+
if (value === null)
|
|
122
|
+
return null;
|
|
123
|
+
if (typeof value === "object" && value !== null && "error" in value && value.error === true) {
|
|
124
|
+
return backendError();
|
|
125
|
+
}
|
|
126
|
+
if (typeof value === "string")
|
|
127
|
+
return value;
|
|
128
|
+
throw backendError();
|
|
129
|
+
});
|
|
130
|
+
},
|
|
131
|
+
async set(namespace, generation, key, value, ttlMs) {
|
|
132
|
+
validateTtl(ttlMs, MAX_TTL_MS);
|
|
133
|
+
if (ttlMs === 0)
|
|
134
|
+
throw new CacheError2("invalid-input");
|
|
135
|
+
const result = await evalScript(SET_SCRIPT, [scopeKey(prefix, namespace), dataKey(prefix, namespace, generation, key)], [generation, value, String(ttlMs)]);
|
|
136
|
+
if (result === 1 || result === "1")
|
|
137
|
+
return true;
|
|
138
|
+
if (result === 0 || result === "0")
|
|
139
|
+
return false;
|
|
140
|
+
throw backendError();
|
|
141
|
+
},
|
|
142
|
+
async delete(namespace, generation, key) {
|
|
143
|
+
await evalScript(DELETE_SCRIPT, [scopeKey(prefix, namespace), dataKey(prefix, namespace, generation, key)], [generation]);
|
|
144
|
+
},
|
|
145
|
+
async invalidate(namespace) {
|
|
146
|
+
await evalScript(INVALIDATE_SCRIPT, [scopeKey(prefix, namespace)], [uuid()]);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
export {
|
|
151
|
+
createRedisCacheAdapter
|
|
152
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenso/cache",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/LioRael/lenso.git",
|
|
8
|
+
"directory": "packages/cache"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./memory": {
|
|
21
|
+
"types": "./dist/memory.d.ts",
|
|
22
|
+
"default": "./dist/memory.js"
|
|
23
|
+
},
|
|
24
|
+
"./redis": {
|
|
25
|
+
"types": "./dist/redis.d.ts",
|
|
26
|
+
"default": "./dist/redis.js"
|
|
27
|
+
},
|
|
28
|
+
"./plugin": {
|
|
29
|
+
"types": "./dist/plugin.d.ts",
|
|
30
|
+
"default": "./dist/plugin.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "rm -rf dist && bun build src/index.ts src/memory.ts src/redis.ts src/plugin.ts --outdir dist --target browser --packages external --splitting && tsc -p tsconfig.build.json",
|
|
35
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
36
|
+
"test": "bun test test"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@lenso/core": "^0.2.1"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@lenso/core": "^0.2.0"
|
|
43
|
+
},
|
|
44
|
+
"peerDependenciesMeta": {
|
|
45
|
+
"@lenso/core": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|