@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 CHANGED
@@ -1,3 +1,238 @@
1
- # Temporary Holding Version
1
+ # @lenso/cache
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 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.
@@ -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 };
@@ -0,0 +1,4 @@
1
+ import { type Cache, type CacheOptions, type JsonValue } from "./contracts";
2
+ export * from "./contracts";
3
+ export { resolveCacheConfig } from "./config";
4
+ export declare function createCache<T extends JsonValue = JsonValue>(options: CacheOptions<T>): Cache<T>;
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
+ };
@@ -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
+ };
@@ -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
+ };
@@ -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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }