@voltro/cache 0.86.0 → 0.88.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.
@@ -1,56 +1,171 @@
1
1
  import { CacheError as e } from "./errors.js";
2
2
  import { CacheStore as t } from "./CacheStore.js";
3
- import { Clock as n, Effect as r, Layer as i, Option as a } from "effect";
4
- import { commandRetry as o, connect as s } from "@voltro/kv/connection";
5
- //#region src/backendRedis.ts
6
- var c = (t) => s({
7
- url: t.url,
8
- driver: t.driver,
9
- ...t.token === void 0 ? {} : { token: t.token }
10
- }).pipe(r.mapError((t) => new e({
11
- operation: "connect",
12
- key: "",
13
- cause: String(t)
14
- }))), l = "\nlocal NOEXP = 9000000000000000\nlocal ttl = tonumber(ARGV[1])\nlocal now = tonumber(ARGV[2])\nlocal score\nif ttl < 0 then score = NOEXP else score = now + ttl end\nredis.call('ZADD', KEYS[1], score, ARGV[3])\nlocal top = redis.call('ZREVRANGE', KEYS[1], 0, 0, 'WITHSCORES')\nlocal maxScore = tonumber(top[2])\nif maxScore >= NOEXP then\n redis.call('PERSIST', KEYS[1])\nelse\n local rem = maxScore - now\n if rem < 1 then rem = 1 end\n redis.call('PEXPIRE', KEYS[1], rem)\nend\nreturn 1\n", u = (t) => r.gen(function* () {
15
- let i = yield* r.acquireRelease(c(t), (e) => r.promise(() => e.close().catch(() => void 0))), s = t.keyPrefix ?? "voltro:cache", u = (e) => `${s}:v:${e}`, d = (e) => `${s}:t:${e}`, f = (t, n, a) => r.tryPromise({
16
- try: () => a(i),
17
- catch: (r) => new e({
18
- operation: t,
19
- key: n,
20
- cause: String(r)
21
- })
22
- }).pipe(r.retry(o)), p = (t, n) => r.try({
23
- try: () => JSON.stringify(n),
24
- catch: (n) => new e({
25
- operation: "serialize",
26
- key: t,
27
- cause: String(n)
28
- })
29
- }), m = (t, n) => r.try({
30
- try: () => a.some(JSON.parse(n)),
31
- catch: (n) => new e({
32
- operation: "deserialize",
33
- key: t,
34
- cause: String(n)
35
- })
36
- }), h = (e) => f("clear", "", (t) => t.scan(e, `${s}:*`, 200)).pipe(r.flatMap(({ cursor: e, keys: t }) => (t.length > 0 ? f("clear", "", (e) => e.del(t)) : r.succeed(0)).pipe(r.flatMap(() => e === "0" ? r.void : h(e))))), g = (e, t, n) => p(e, t).pipe(r.flatMap((t) => n !== void 0 && n > 0 ? f("set", e, (r) => r.psetex(u(e), Math.ceil(n), t)) : f("set", e, (n) => n.set(u(e), t))), r.asVoid);
3
+ import { Effect as n, Layer as r, Option as i } from "effect";
4
+ import { CACHE_ATOMIC_SCRIPT as a, RedisConnections as o, cacheAtomicArray as s, cacheAtomicComponent as c, cacheAtomicKeys as l, cacheAtomicMember as u, cacheAtomicRoot as d, cacheAtomicScanPage as f, cacheAtomicSnapshot as p, cacheAtomicTtl as m, cacheAtomicValueSnapshots as h, decodeCacheAtomicCommand as g, decodeCacheAtomicReply as _ } from "@voltro/kv/connection";
5
+ // src/backendRedis.ts
6
+ var v = (t, r = "voltro:cache") => {
7
+ let o = d(r), v = (t, n, r) => new e({
8
+ operation: t,
9
+ key: n,
10
+ cause: r
11
+ }), y = (e, t, r) => n.try({
12
+ try: r,
13
+ catch: () => v(e, t, "Cache command or stored metadata is invalid.")
14
+ }), b = (e, r, i) => n.gen(function* () {
15
+ let s = yield* y(e, r, () => {
16
+ let e = g(i);
17
+ return {
18
+ keys: l(o, e),
19
+ body: JSON.stringify(e)
20
+ };
21
+ }), c = yield* n.tryPromise({
22
+ try: () => t.eval(a, s.keys.length, ...s.keys, s.body),
23
+ catch: () => v(e, r, "Cache transport failed; a write outcome may be unknown.")
24
+ }).pipe(n.uninterruptible), u = yield* y(e, r, () => _(c));
25
+ return u.status === "limit" ? yield* n.fail(v(e, r, "Cache operation exceeds the server limit.")) : u;
26
+ }), x = (e, t, r) => b(e, t, r).pipe(n.flatMap((r) => r.status === "ok" ? n.succeed(r.value) : n.fail(v(e, t, "Cache observation unexpectedly conflicted.")))), S = (e, t, r) => x(e, t, {
27
+ op: "inspect",
28
+ key: r
29
+ }).pipe(n.flatMap((n) => y(e, t, () => p(n)))), C = (e, t, r) => n.gen(function* () {
30
+ for (let n = 0; n < 8; n++) {
31
+ let n = yield* r(), i = yield* b(e, t, n.command);
32
+ if (i.status !== "conflict") return yield* y(e, t, () => n.decode(i.value));
33
+ }
34
+ return yield* n.fail(v(e, t, "Cache changed during the operation; retry with a fresh observation."));
35
+ }), w = (e) => {
36
+ if (typeof e != "number" || !Number.isSafeInteger(e) || e < 0) throw Error("Invalid cache count");
37
+ return e;
38
+ }, T = (e) => C("get", e, () => n.gen(function* () {
39
+ let t = yield* y("get", e, () => c(e));
40
+ return {
41
+ command: {
42
+ op: "read",
43
+ key: t,
44
+ snapshot: yield* S("get", e, t)
45
+ },
46
+ decode: (e) => {
47
+ if (e !== null && typeof e != "string") throw Error("Invalid cache payload");
48
+ return e;
49
+ }
50
+ };
51
+ })), E = (e, t, r) => C("set", e, () => n.gen(function* () {
52
+ let n = yield* y("set", e, () => {
53
+ let n = JSON.stringify(t);
54
+ if (n === void 0) throw Error("Value is not JSON serializable");
55
+ return {
56
+ id: c(e),
57
+ tags: [...new Set((r?.tags ?? []).map(c))].sort(),
58
+ value: n,
59
+ ttlMs: m(r?.ttlMs),
60
+ version: globalThis.crypto.randomUUID(),
61
+ revision: globalThis.crypto.randomUUID(),
62
+ fence: r?.fence ?? null
63
+ };
64
+ }), i = yield* S("set", e, n.id);
65
+ return {
66
+ command: {
67
+ op: "write",
68
+ key: n.id,
69
+ snapshot: i,
70
+ version: n.version,
71
+ revision: n.revision,
72
+ tags: n.tags,
73
+ value: n.value,
74
+ ttlMs: n.ttlMs,
75
+ fence: n.fence
76
+ },
77
+ decode: (e) => w(e) > 0
78
+ };
79
+ }));
37
80
  return {
38
- get: (e) => f("get", e, (t) => t.get(u(e))).pipe(r.flatMap((t) => t === null ? r.succeed(a.none()) : m(e, t))),
39
- set: (e, t, n) => g(e, t, n?.ttlMs),
40
- setWithTags: (e, t, i) => g(e, t, i.ttlMs).pipe(r.flatMap(() => {
41
- let t = i.tags ?? [];
42
- if (t.length === 0) return r.void;
43
- let a = i.ttlMs !== void 0 && i.ttlMs > 0 ? Math.ceil(i.ttlMs) : -1;
44
- return n.currentTimeMillis.pipe(r.flatMap((n) => r.forEach(t, (t) => f("setWithTags", e, (r) => r.eval(l, 1, d(t), a, n, e)), { discard: !0 })));
81
+ get: (e) => T(e).pipe(n.flatMap((t) => t === null ? n.succeed(i.none()) : y("get", e, () => i.some(JSON.parse(t))))),
82
+ set: (e, t, r) => E(e, t, r).pipe(n.asVoid),
83
+ setWithTags: (e, t, n) => E(e, t, n),
84
+ fence: () => x("fence", "", { op: "fence" }).pipe(n.flatMap((e) => y("fence", "", () => w(e)))),
85
+ claim: (e, t) => n.gen(function* () {
86
+ let n = yield* y("claim", e, () => ({
87
+ op: "write",
88
+ key: c(e),
89
+ snapshot: null,
90
+ version: globalThis.crypto.randomUUID(),
91
+ revision: globalThis.crypto.randomUUID(),
92
+ tags: [],
93
+ value: "true",
94
+ ttlMs: m(t),
95
+ fence: null
96
+ }));
97
+ return (yield* b("claim", e, n)).status === "ok";
98
+ }),
99
+ has: (e) => T(e).pipe(n.map((e) => e !== null)),
100
+ remove: (e) => C("remove", e, () => n.gen(function* () {
101
+ let t = yield* y("remove", e, () => ({
102
+ id: c(e),
103
+ revision: globalThis.crypto.randomUUID()
104
+ })), n = yield* S("remove", e, t.id);
105
+ return {
106
+ command: {
107
+ op: "remove",
108
+ key: t.id,
109
+ snapshot: n,
110
+ revision: t.revision
111
+ },
112
+ decode: (e) => w(e) > 0
113
+ };
45
114
  })),
46
- remove: (e) => f("remove", e, (t) => t.del([u(e)])).pipe(r.map((e) => e > 0)),
47
- has: (e) => f("has", e, (t) => t.exists(u(e))).pipe(r.map((e) => e > 0)),
48
- clear: () => h("0"),
49
- invalidateTag: (e) => f("invalidateTag", e, (t) => t.zrange(d(e))).pipe(r.flatMap((t) => f("invalidateTag", e, async (n) => {
50
- let r = t.map(u), i = r.length > 0 ? await n.del(r) : 0;
51
- return await n.del([d(e)]), i;
52
- })))
115
+ invalidateTag: (e) => C("invalidateTag", e, () => n.gen(function* () {
116
+ let t = yield* y("invalidateTag", e, () => ({
117
+ id: c(e),
118
+ revision: globalThis.crypto.randomUUID()
119
+ })), n = yield* x("invalidateTag", e, {
120
+ op: "tag",
121
+ tag: t.id
122
+ }), r = yield* y("invalidateTag", e, () => s(n).map((e) => (u(e), String(e))).sort()), i = [...new Set(r.map((e) => u(e).key))].sort(), a = yield* x("invalidateTag", e, {
123
+ op: "inspect-values",
124
+ keys: i
125
+ }), o = yield* y("invalidateTag", e, () => h(a));
126
+ return {
127
+ command: {
128
+ op: "invalidate",
129
+ tag: t.id,
130
+ members: r,
131
+ snapshots: o,
132
+ revision: t.revision
133
+ },
134
+ decode: w
135
+ };
136
+ })),
137
+ clear: () => C("clear", "", () => n.gen(function* () {
138
+ let e = yield* x("clear", "", { op: "revision" });
139
+ yield* y("clear", "", () => g({
140
+ op: "clear",
141
+ keys: [],
142
+ revision: e
143
+ }));
144
+ let t = /* @__PURE__ */ new Set(), r = /* @__PURE__ */ new Set(), i = "0";
145
+ do {
146
+ if (r.has(i)) return yield* n.fail(v("clear", "", "Cache scan did not progress."));
147
+ r.add(i);
148
+ let e = yield* x("clear", "", {
149
+ op: "scan",
150
+ cursor: i
151
+ }), a = yield* y("clear", "", () => f(o, e));
152
+ a.keys.forEach((e) => t.add(e)), i = a.cursor;
153
+ } while (i !== "0");
154
+ return {
155
+ command: {
156
+ op: "clear",
157
+ keys: [...t].sort(),
158
+ revision: e
159
+ },
160
+ decode: () => {}
161
+ };
162
+ }))
53
163
  };
54
- }), d = (e) => i.effect(t, u(e));
55
- //#endregion
56
- export { d as layerRedis };
164
+ }, y = (i = {}) => r.effect(t, n.gen(function* () {
165
+ return v(yield* (yield* o).client(i.connectionName ?? "cache").pipe(n.mapError(() => new e({
166
+ operation: "connect",
167
+ key: "",
168
+ cause: "Connection unavailable"
169
+ }))), i.keyPrefix);
170
+ }));
171
+ export { y as layerRedis, v as makeRedisCacheStore };
package/dist/errors.js CHANGED
@@ -1,9 +1,8 @@
1
1
  import { Schema as e } from "effect";
2
- //#region src/errors.ts
2
+ // src/errors.ts
3
3
  var t = class extends e.TaggedError()("CacheError", {
4
4
  operation: e.String,
5
5
  key: e.String,
6
6
  cause: e.String
7
7
  }) {};
8
- //#endregion
9
8
  export { t as CacheError };
package/dist/index.d.ts CHANGED
@@ -1,8 +1,12 @@
1
+ import { CACHE_FENCE_RETENTION_MS } from '@voltro/kv/connection';
1
2
  import { Config } from 'effect';
2
3
  import { Context } from 'effect';
3
4
  import { Effect } from 'effect';
4
5
  import { Layer } from 'effect';
5
6
  import { Option } from 'effect';
7
+ import { RedisConnections } from '@voltro/kv/connection';
8
+ import { RespClient } from '@voltro/kv/connection';
9
+ import { RespConnections } from '@voltro/kv/connection';
6
10
  import { Schema } from 'effect';
7
11
  import { Scope } from 'effect';
8
12
  import { Stream } from 'effect';
@@ -21,13 +25,26 @@ declare const Cache_base: Context.ServiceClass<Cache, "@voltro/Cache", {
21
25
  /** Write-through with the SAME storage shape `wrap` uses (incl. the
22
26
  * SWR envelope) — so a value written here is read back identically
23
27
  * by a later `wrap`. The dispatcher uses this to keep cached
24
- * snapshots warm from its live recompute. */
25
- readonly put: <A>(key: string, options: WrapOptions, value: A) => Effect.Effect<void, CacheError>;
28
+ * snapshots warm from its live recompute. Pass the `fence()` observed
29
+ * before the value was read, so an earlier, slower read can never
30
+ * overwrite a later one. Resolves `false` when the fence voided it. */
31
+ readonly put: <A>(key: string, options: WrapOptions, value: A, fence?: CacheFence) => Effect.Effect<boolean, CacheError>;
32
+ /** The store's invalidation position — observe it before reading the
33
+ * value you will `put`. */
34
+ readonly fence: () => Effect.Effect<CacheFence, CacheError>;
26
35
  readonly remove: (key: string) => Effect.Effect<boolean, CacheError>;
27
36
  readonly has: (key: string) => Effect.Effect<boolean, CacheError>;
28
37
  readonly clear: () => Effect.Effect<void, CacheError>;
29
38
  readonly invalidateTag: (tag: string) => Effect.Effect<number, CacheError>;
30
39
  readonly wrap: <A, E, R>(key: string, options: WrapOptions, compute: Effect.Effect<A, E, R>) => Effect.Effect<A, E | CacheError, R>;
40
+ /** Cache-aside whose store decision the compute makes — see the
41
+ * `wrapDynamic` note above. Best-effort: never fails on the cache. */
42
+ readonly wrapDynamic: <A, E, R>(key: string, options: DynamicWrapOptions, compute: Effect.Effect<DynamicComputed<A>, E, R>, read: (stored: unknown) => Effect.Effect<Option.Option<A>>) => Effect.Effect<{
43
+ readonly value: A;
44
+ readonly served: DynamicServed;
45
+ }, E, R>;
46
+ /** Per-label counters kept by `wrapDynamic`. */
47
+ readonly queryStats: () => Effect.Effect<Readonly<Record<string, DynamicLabelStats>>>;
31
48
  /** A named, key-prefixed partition with its own default ttl/swr/tags.
32
49
  * Keys become `<name>:<key>`; per-call options override the defaults;
33
50
  * tags stay global (unprefixed) so bus invalidation still reaches
@@ -39,6 +56,7 @@ declare const Cache_base: Context.ServiceClass<Cache, "@voltro/Cache", {
39
56
  hits: number;
40
57
  misses: number;
41
58
  hitRate: number;
59
+ voided: number;
42
60
  }>;
43
61
  }> & {
44
62
  readonly make: Effect.Effect<{
@@ -49,13 +67,26 @@ declare const Cache_base: Context.ServiceClass<Cache, "@voltro/Cache", {
49
67
  /** Write-through with the SAME storage shape `wrap` uses (incl. the
50
68
  * SWR envelope) — so a value written here is read back identically
51
69
  * by a later `wrap`. The dispatcher uses this to keep cached
52
- * snapshots warm from its live recompute. */
53
- readonly put: <A>(key: string, options: WrapOptions, value: A) => Effect.Effect<void, CacheError>;
70
+ * snapshots warm from its live recompute. Pass the `fence()` observed
71
+ * before the value was read, so an earlier, slower read can never
72
+ * overwrite a later one. Resolves `false` when the fence voided it. */
73
+ readonly put: <A>(key: string, options: WrapOptions, value: A, fence?: CacheFence) => Effect.Effect<boolean, CacheError>;
74
+ /** The store's invalidation position — observe it before reading the
75
+ * value you will `put`. */
76
+ readonly fence: () => Effect.Effect<CacheFence, CacheError>;
54
77
  readonly remove: (key: string) => Effect.Effect<boolean, CacheError>;
55
78
  readonly has: (key: string) => Effect.Effect<boolean, CacheError>;
56
79
  readonly clear: () => Effect.Effect<void, CacheError>;
57
80
  readonly invalidateTag: (tag: string) => Effect.Effect<number, CacheError>;
58
81
  readonly wrap: <A, E, R>(key: string, options: WrapOptions, compute: Effect.Effect<A, E, R>) => Effect.Effect<A, E | CacheError, R>;
82
+ /** Cache-aside whose store decision the compute makes — see the
83
+ * `wrapDynamic` note above. Best-effort: never fails on the cache. */
84
+ readonly wrapDynamic: <A, E, R>(key: string, options: DynamicWrapOptions, compute: Effect.Effect<DynamicComputed<A>, E, R>, read: (stored: unknown) => Effect.Effect<Option.Option<A>>) => Effect.Effect<{
85
+ readonly value: A;
86
+ readonly served: DynamicServed;
87
+ }, E, R>;
88
+ /** Per-label counters kept by `wrapDynamic`. */
89
+ readonly queryStats: () => Effect.Effect<Readonly<Record<string, DynamicLabelStats>>>;
59
90
  /** A named, key-prefixed partition with its own default ttl/swr/tags.
60
91
  * Keys become `<name>:<key>`; per-call options override the defaults;
61
92
  * tags stay global (unprefixed) so bus invalidation still reaches
@@ -67,10 +98,13 @@ declare const Cache_base: Context.ServiceClass<Cache, "@voltro/Cache", {
67
98
  hits: number;
68
99
  misses: number;
69
100
  hitRate: number;
101
+ voided: number;
70
102
  }>;
71
103
  }, never, CacheStore>;
72
104
  };
73
105
 
106
+ export { CACHE_FENCE_RETENTION_MS }
107
+
74
108
  export declare class CacheError extends CacheError_base {
75
109
  }
76
110
 
@@ -84,8 +118,18 @@ declare const CacheError_base: Schema.Class<CacheError, Schema.TaggedStruct<"Cac
84
118
  readonly cause: Schema.String;
85
119
  }>, YieldableError>;
86
120
 
121
+ /**
122
+ * The store's invalidation position, observed before a compute. Opaque: only
123
+ * hand it back to `setWithTags`. A fence older than
124
+ * `CACHE_FENCE_RETENTION_MS` proves nothing; `Cache.wrap` does not store such
125
+ * a compute.
126
+ */
127
+ export declare type CacheFence = number;
128
+
87
129
  export declare namespace CacheLayer {
88
130
  export {
131
+ CacheStoreLayerOptions,
132
+ makeStoreLayer,
89
133
  storeLayer,
90
134
  layer
91
135
  }
@@ -126,6 +170,10 @@ export declare interface CacheSetWithTagsOptions extends CacheSetOptions {
126
170
  /** Tags this entry belongs to. `invalidateTag(tag)` drops every entry
127
171
  * carrying that tag. */
128
172
  readonly tags?: ReadonlyArray<string>;
173
+ /** A `fence()` observed BEFORE the value was computed. When any of `tags`
174
+ * was invalidated since — anywhere that shares this store — the write is
175
+ * void: the value may predate the write behind that invalidation. */
176
+ readonly fence?: CacheFence;
129
177
  }
130
178
 
131
179
  export declare class CacheStore extends CacheStore_base {
@@ -133,6 +181,13 @@ export declare class CacheStore extends CacheStore_base {
133
181
 
134
182
  declare const CacheStore_base: Context.ServiceClass<CacheStore, "@voltro/CacheStore", CacheStoreShape>;
135
183
 
184
+ declare interface CacheStoreLayerOptions {
185
+ readonly backend?: 'memory' | 'redis';
186
+ readonly connectionName?: string;
187
+ readonly keyPrefix?: string;
188
+ readonly connections?: RespConnections;
189
+ }
190
+
136
191
  export declare interface CacheStoreShape {
137
192
  /** Read a value. `None` on miss OR expiry (expired entries are lazily
138
193
  * evicted on read). */
@@ -140,8 +195,15 @@ export declare interface CacheStoreShape {
140
195
  /** Write a value with an optional ms TTL. Overwrites any existing entry
141
196
  * (clearing its old tag memberships). */
142
197
  readonly set: (key: string, value: unknown, options?: CacheSetOptions) => Effect.Effect<void, CacheError>;
143
- /** Write a value AND register it under the given tags. */
144
- readonly setWithTags: (key: string, value: unknown, options: CacheSetWithTagsOptions) => Effect.Effect<void, CacheError>;
198
+ /** Write a value AND register it under the given tags. Resolves `false`
199
+ * when `options.fence` voided the write (nothing was stored). */
200
+ readonly setWithTags: (key: string, value: unknown, options: CacheSetWithTagsOptions) => Effect.Effect<boolean, CacheError>;
201
+ /** Current invalidation position — see {@link CacheFence}. Every
202
+ * `invalidateTag` advances it, for every participant sharing the store. */
203
+ readonly fence: () => Effect.Effect<CacheFence, CacheError>;
204
+ /** Claim `key` for `ttlMs` if nobody holds it — the cluster single-flight
205
+ * lease. Optional: a process-local store has nothing to coordinate. */
206
+ readonly claim?: (key: string, ttlMs: number) => Effect.Effect<boolean, CacheError>;
145
207
  /** Delete one key (and its tag memberships). Resolves `true` if it existed. */
146
208
  readonly remove: (key: string) => Effect.Effect<boolean, CacheError>;
147
209
  /** Existence check honouring expiry, without deserializing the value. */
@@ -149,8 +211,9 @@ export declare interface CacheStoreShape {
149
211
  /** Drop every entry in this cache's namespace. Scoped to the backend's
150
212
  * key-prefix — never a global flush on a shared server. */
151
213
  readonly clear: () => Effect.Effect<void, CacheError>;
152
- /** Delete every key registered under `tag`, then drop the tag index.
153
- * Resolves the count of keys removed. */
214
+ /** Delete every key registered under `tag`, then drop the tag index, and
215
+ * advance the fence for `tag` (also when nothing carried it). Resolves the
216
+ * count of keys removed. `clear` does not reset the fence. */
154
217
  readonly invalidateTag: (tag: string) => Effect.Effect<number, CacheError>;
155
218
  }
156
219
 
@@ -187,6 +250,52 @@ export declare const cosineSimilarity: (a: ReadonlyArray<number>, b: ReadonlyArr
187
250
  */
188
251
  export declare const depsForChange: (c: ChangeSignal) => ReadonlyArray<string>;
189
252
 
253
+ /** A `wrapDynamic` compute's result: the value for THIS caller, and whether
254
+ * (and how) it may be stored for the next. */
255
+ export declare interface DynamicComputed<A> {
256
+ readonly value: A;
257
+ readonly store?: DynamicStore | undefined;
258
+ /** The compute found the call is not a candidate for this cache at all (a
259
+ * query whose handler returned a descriptor): nothing stored, and not
260
+ * counted as a result the cache refused to keep. */
261
+ readonly notApplicable?: boolean | undefined;
262
+ }
263
+
264
+ export declare type DynamicEvent = keyof DynamicLabelStats;
265
+
266
+ /** Per-label counters — `queryStats()`, the dashboard Cache panel and the
267
+ * `voltro_query_cache_events_total{query,event}` series. */
268
+ export declare interface DynamicLabelStats {
269
+ readonly hits: number;
270
+ readonly stale: number;
271
+ readonly misses: number;
272
+ readonly stored: number;
273
+ readonly voided: number;
274
+ readonly decodeMisses: number;
275
+ readonly uncacheable: number;
276
+ readonly oversized: number;
277
+ }
278
+
279
+ export declare type DynamicServed = 'hit' | 'stale' | 'miss';
280
+
281
+ /** What a `wrapDynamic` compute stores, decided AFTER it ran: the encoded
282
+ * value and the tags it actually depends on. Absent ⇒ not stored. */
283
+ export declare interface DynamicStore {
284
+ readonly stored: unknown;
285
+ readonly tags: ReadonlyArray<string>;
286
+ }
287
+
288
+ export declare interface DynamicWrapOptions {
289
+ readonly ttlMs: number;
290
+ readonly swrMs?: number | undefined;
291
+ /** Cluster single-flight: on a miss, claim the key for this long on a
292
+ * store that can (`CacheStore.claim`); other participants wait for the
293
+ * claimant's value instead of computing it too, up to this bound. */
294
+ readonly leaseMs?: number | undefined;
295
+ /** Bounded label the per-label counters are kept under (a query name). */
296
+ readonly label: string;
297
+ }
298
+
190
299
  export declare class InvalidationBus extends InvalidationBus_base {
191
300
  /** The bus with its own scope: sinks live as long as the layer. */
192
301
  static readonly layer: Layer.Layer<InvalidationBus, never, never>;
@@ -214,7 +323,10 @@ declare const layer: Layer.Layer<Cache, Config.ConfigError | CacheError>;
214
323
  export declare const layerMemory: (options?: MemoryCacheOptions) => Layer.Layer<CacheStore>;
215
324
 
216
325
  /** `Layer<CacheStore>` backed by a RESP server (Redis/Valkey/KeyDB/Dragonfly/Upstash). */
217
- export declare const layerRedis: (options: RedisCacheOptions) => Layer.Layer<CacheStore, CacheError>;
326
+ export declare const layerRedis: (options?: RedisCacheOptions) => Layer.Layer<CacheStore, CacheError, RedisConnections>;
327
+
328
+ /** Cache semantics over a registry-owned client; never closes its socket. */
329
+ export declare const makeRedisCacheStore: (client: RespClient, prefix?: string) => CacheStoreShape;
218
330
 
219
331
  /**
220
332
  * Build a {@link SemanticCacheShape} over a resolved {@link CacheStoreShape}.
@@ -225,8 +337,19 @@ export declare const layerRedis: (options: RedisCacheOptions) => Layer.Layer<Cac
225
337
  */
226
338
  export declare const makeSemanticCache: (store: CacheStoreShape, _options?: SemanticCacheOptions) => Effect.Effect<SemanticCacheShape>;
227
339
 
340
+ /**
341
+ * `Layer<CacheStore>` whose backend (memory | redis) + connection are chosen
342
+ * from the environment. This is the STORE — value/TTL/tag storage — one level
343
+ * below `Cache`. Exposed so a caller can build MORE than one service on ONE
344
+ * store instance: `Layer.provide(storeLayer)` on a merged layer memoises a
345
+ * SINGLE `CacheStore`, shared by every consumer in it (that is how the query
346
+ * `Cache` and a `SemanticCache` end up over the same backend — see
347
+ * `cli/src/cacheFacade.ts`).
348
+ */
349
+ declare const makeStoreLayer: (options?: CacheStoreLayerOptions) => Layer.Layer<CacheStore, Config.ConfigError | CacheError>;
350
+
228
351
  export declare interface MemoryCacheOptions {
229
- /** Hard cap on live entry count. When exceeded, oldest-by-insertion
352
+ /** Hard cap on live entry count. When exceeded, least-recently-used
230
353
  * entries are evicted — the backstop for entries that expired but were
231
354
  * never read (lazy eviction never fired). Omit for unbounded. */
232
355
  readonly maxEntries?: number;
@@ -247,11 +370,8 @@ export declare interface PartitionDefaults {
247
370
  }
248
371
 
249
372
  export declare interface RedisCacheOptions {
250
- readonly driver: 'resp' | 'http';
251
- /** Connection URL (`redis://…` / `rediss://…` for RESP, the REST URL for http). */
252
- readonly url: string;
253
- /** Auth token — http (Upstash REST) driver only. */
254
- readonly token?: string;
373
+ /** Named pooled connection; defaults to cache's environment convention. */
374
+ readonly connectionName?: string;
255
375
  readonly keyPrefix?: string;
256
376
  }
257
377
 
@@ -340,15 +460,6 @@ export declare interface SemanticPutOptions {
340
460
  readonly key?: string;
341
461
  }
342
462
 
343
- /**
344
- * `Layer<CacheStore>` whose backend (memory | redis) + connection are chosen
345
- * from the environment. This is the STORE — value/TTL/tag storage — one level
346
- * below `Cache`. Exposed so a caller can build MORE than one service on ONE
347
- * store instance: `Layer.provide(storeLayer)` on a merged layer memoises a
348
- * SINGLE `CacheStore`, shared by every consumer in it (that is how the query
349
- * `Cache` and a `SemanticCache` end up over the same backend — see
350
- * `cli/src/cacheFacade.ts`).
351
- */
352
463
  declare const storeLayer: Layer.Layer<CacheStore, Config.ConfigError | CacheError>;
353
464
 
354
465
  /** The dependency tag for a whole table. Coarser than {@link rowDep}: it also
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { CacheError as e } from "./errors.js";
2
- import { CacheStore as t } from "./CacheStore.js";
3
- import { layerMemory as n } from "./backendMemory.js";
4
- import { layerRedis as r } from "./backendRedis.js";
5
- import { Cache as i } from "./Cache.js";
6
- import { SemanticCache as a, cosineSimilarity as o, depsForChange as s, makeSemanticCache as c, rowDep as l, tableDep as u } from "./semantic.js";
7
- import { InvalidationBus as d } from "./invalidationBus.js";
8
- import { layer_exports as f } from "./layer.js";
9
- export { i as Cache, e as CacheError, f as CacheLayer, t as CacheStore, d as InvalidationBus, a as SemanticCache, o as cosineSimilarity, s as depsForChange, n as layerMemory, r as layerRedis, c as makeSemanticCache, l as rowDep, u as tableDep };
2
+ import { CACHE_FENCE_RETENTION_MS as t, CacheStore as n } from "./CacheStore.js";
3
+ import { layerMemory as r } from "./backendMemory.js";
4
+ import { layerRedis as i, makeRedisCacheStore as a } from "./backendRedis.js";
5
+ import { Cache as o } from "./Cache.js";
6
+ import { SemanticCache as s, cosineSimilarity as c, depsForChange as l, makeSemanticCache as u, rowDep as d, tableDep as f } from "./semantic.js";
7
+ import { InvalidationBus as p } from "./invalidationBus.js";
8
+ import { layer_exports as m } from "./layer.js";
9
+ export { t as CACHE_FENCE_RETENTION_MS, o as Cache, e as CacheError, m as CacheLayer, n as CacheStore, p as InvalidationBus, s as SemanticCache, c as cosineSimilarity, l as depsForChange, r as layerMemory, i as layerRedis, a as makeRedisCacheStore, u as makeSemanticCache, d as rowDep, f as tableDep };
@@ -1,5 +1,5 @@
1
1
  import { Context as e, Effect as t, Layer as n, PubSub as r, Scope as i, Stream as a } from "effect";
2
- //#region src/invalidationBus.ts
2
+ // src/invalidationBus.ts
3
3
  var o = class o extends e.Service()("@voltro/InvalidationBus", { make: t.gen(function* () {
4
4
  let e = yield* r.unbounded(), n = yield* t.scope;
5
5
  return {
@@ -10,5 +10,4 @@ var o = class o extends e.Service()("@voltro/InvalidationBus", { make: t.gen(fun
10
10
  }) }) {
11
11
  static layer = n.effect(o, o.make);
12
12
  };
13
- //#endregion
14
13
  export { o as InvalidationBus };
package/dist/layer.js CHANGED
@@ -3,23 +3,22 @@ import { layerMemory as t } from "./backendMemory.js";
3
3
  import { layerRedis as n } from "./backendRedis.js";
4
4
  import { Cache as r } from "./Cache.js";
5
5
  import { Config as i, Effect as a, Layer as o, Option as s } from "effect";
6
- //#region src/layer.ts
7
- var c = /* @__PURE__ */ e({
8
- layer: () => f,
9
- storeLayer: () => d
10
- }), l = a.gen(function* () {
6
+ import { RedisConnections as c, layerConnections as l } from "@voltro/kv/connection";
7
+ // src/layer.ts
8
+ var u = /* @__PURE__ */ e({
9
+ layer: () => h,
10
+ makeStoreLayer: () => p,
11
+ storeLayer: () => m
12
+ }), d = a.gen(function* () {
11
13
  let e = yield* i.Int("CACHE_MAX_ENTRIES").pipe(i.option), n = s.isSome(e) ? { maxEntries: e.value } : {};
12
14
  return t(n);
13
- }), u = a.gen(function* () {
14
- let e = yield* i.String("CACHE_REDIS_URL").pipe(i.orElse(() => i.String("REDIS_URL"))), t = yield* i.Literals(["resp", "http"], "CACHE_REDIS_DRIVER").pipe(i.withDefault("resp")), r = yield* i.String("CACHE_REDIS_TOKEN").pipe(i.orElse(() => i.String("UPSTASH_REDIS_REST_TOKEN")), i.option), a = yield* i.String("CACHE_KEY_PREFIX").pipe(i.option), o = {
15
- driver: t,
16
- url: e,
17
- ...s.isSome(r) ? { token: r.value } : {},
18
- ...s.isSome(a) ? { keyPrefix: a.value } : {}
15
+ }), f = (e) => a.gen(function* () {
16
+ let t = yield* i.String("CACHE_KEY_PREFIX").pipe(i.option), r = {
17
+ ...e.connectionName === void 0 ? {} : { connectionName: e.connectionName },
18
+ ...s.isSome(t) ? { keyPrefix: t.value } : e.keyPrefix === void 0 ? {} : { keyPrefix: e.keyPrefix }
19
19
  };
20
- return n(o);
21
- }), d = o.unwrap(a.gen(function* () {
22
- return (yield* i.Literals(["memory", "redis"], "CACHE_BACKEND").pipe(i.withDefault("memory"))) === "redis" ? yield* u : yield* l;
23
- })), f = r.layer.pipe(o.provide(d));
24
- //#endregion
25
- export { f as layer, c as layer_exports, d as storeLayer };
20
+ return n(r).pipe(o.provide(e.connections === void 0 ? l : o.succeed(c, e.connections)));
21
+ }), p = (e = {}) => o.unwrap(a.gen(function* () {
22
+ return (yield* i.Literals(["memory", "redis"], "CACHE_BACKEND").pipe(i.withDefault(e.backend ?? "memory"))) === "redis" ? yield* f(e) : yield* d;
23
+ })), m = p(), h = r.layer.pipe(o.provide(m));
24
+ export { h as layer, u as layer_exports, p as makeStoreLayer, m as storeLayer };
package/dist/semantic.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { CacheStore as e } from "./CacheStore.js";
2
2
  import { Context as t, Effect as n, HashMap as r, HashSet as i, Layer as a, Option as o, Ref as s } from "effect";
3
- //#region src/semantic.ts
3
+ // src/semantic.ts
4
4
  var c = (e, t) => `row:${e}:${t}`, l = (e) => `table:${e}`, u = (e) => {
5
5
  let t = l(e.table);
6
6
  return e.op === "insert" || e.id === void 0 ? [t] : [c(e.table, e.id), t];
@@ -127,5 +127,4 @@ var c = (e, t) => `row:${e}:${t}`, l = (e) => `table:${e}`, u = (e) => {
127
127
  }) }) {
128
128
  static layer = a.effect(r, r.make);
129
129
  };
130
- //#endregion
131
130
  export { v as SemanticCache, d as cosineSimilarity, u as depsForChange, _ as makeSemanticCache, c as rowDep, l as tableDep };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voltro/cache",
3
- "version": "0.86.0",
3
+ "version": "0.88.0",
4
4
  "description": "Effect-native cache primitive — memory + RESP (Redis/Valkey/KeyDB/Dragonfly/Upstash) backends, tag invalidation, single-flight wrap.",
5
5
  "keywords": [
6
6
  "voltro",
@@ -33,10 +33,10 @@
33
33
  "node": ">=24.0.0"
34
34
  },
35
35
  "dependencies": {
36
- "@voltro/kv": "0.86.0"
36
+ "@voltro/kv": "0.88.0"
37
37
  },
38
38
  "peerDependencies": {
39
- "effect": "4.0.1"
39
+ "effect": "4.1.0"
40
40
  },
41
41
  "publishConfig": {
42
42
  "access": "public"