bazis 0.98.33 → 0.99.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.
Files changed (43) hide show
  1. package/cli/templates/module.ts +4 -64
  2. package/cli/templates/passport.ts +14 -18
  3. package/core/agent/AgentContextBuilder.ts +4 -98
  4. package/core/agent/AgentRegistry.ts +79 -473
  5. package/core/agent/AgentRuntime.ts +47 -19
  6. package/core/agent/AgentToolExecutor.ts +3 -3
  7. package/core/agent/README.md +89 -181
  8. package/core/agent/index.ts +3 -31
  9. package/core/agent/internal/AgentExecutionDriver.ts +37 -392
  10. package/core/agent/internal/AgentExecutionState.ts +1 -9
  11. package/core/agent/metadata.ts +8 -303
  12. package/core/agent/semantic.ts +0 -4
  13. package/core/agent/session/contracts.ts +5 -7
  14. package/core/agent/session/entities.ts +0 -1
  15. package/core/cache/COMPATIBILITY.md +4 -4
  16. package/core/cache/README.md +2 -2
  17. package/core/cache/SPEC.md +30 -48
  18. package/core/cache/decorators/Cacheable.ts +4 -3
  19. package/core/cache/decorators/CacheableRedis.ts +3 -4
  20. package/core/cache/di/classProviderHook.ts +31 -0
  21. package/core/cache/distributed/DistributedCache.ts +96 -25
  22. package/core/cache/distributed/IDistributedCache.ts +9 -0
  23. package/core/cache/distributed/codecs.ts +49 -2
  24. package/core/cache/distributed/index.ts +1 -1
  25. package/core/cache/errors/CacheError.ts +17 -2
  26. package/core/cache/http/outputRedisCacheMiddleware.ts +11 -2
  27. package/core/cache/index.ts +2 -6
  28. package/core/cache/services/cacheProxy.ts +6 -2
  29. package/core/generatedRuntime.ts +0 -31
  30. package/core/infra/cache/RedisDistributedCacheBackend.ts +3 -0
  31. package/core/infra/connectors/codex/CodexAppServerClient.ts +1 -1
  32. package/core/infra/connectors/llm.ts +15 -1
  33. package/core/infra/connectors/llmRouter.ts +4 -0
  34. package/core/infra/connectors/redis.ts +9 -1
  35. package/core/scripts/di-generate.ts +28 -42
  36. package/core/scripts/tool-codegen.ts +122 -0
  37. package/index.ts +3 -24
  38. package/package.json +1 -1
  39. package/core/agent/decorators/Agent.ts +0 -11
  40. package/core/agent/decorators/Prompt.ts +0 -11
  41. package/core/agent/decorators/Task.ts +0 -29
  42. package/core/agent/generated/catalog.ts +0 -26
  43. package/core/scripts/agent-codegen.ts +0 -1166
@@ -19,7 +19,7 @@ Quick navigation:
19
19
  - [7. `@OutputRedisCache` — HTTP output cache (distributed)](#7-outputrediscache--http-output-cache-distributed)
20
20
  - [8. `@Cacheable`: service method cache (memory)](#8-cacheable-service-method-cache-memory)
21
21
  - [9. `@CacheableRedis`: service method cache (distributed)](#9-cacheableredis-service-method-cache-distributed)
22
- - [10. DI providers: `cachedSingleton` / `cachedScoped` (+ auto-hook)](#10-di-providers-cachedsingleton--cachedscoped--auto-hook)
22
+ - [10. DI registration: `singleton` / `scoped` with the cache hook](#10-di-registration-singleton--scoped-with-the-cache-hook)
23
23
  - [11. `ICache`: programmatic API](#11-icache-programmatic-api)
24
24
  - [12. Output cache key building](#12-output-cache-key-building)
25
25
  - [13. Anti-stampede and concurrent miss](#13-anti-stampede-and-concurrent-miss)
@@ -68,7 +68,6 @@ import {
68
68
  OutputRedisCache,
69
69
  Cacheable,
70
70
  CacheableRedis,
71
- cachedScoped,
72
71
  ICache,
73
72
  } from "@/core/cache";
74
73
  import { composeRouteMiddlewareComposers } from "@/core/http";
@@ -116,8 +115,8 @@ import { redisConnect } from "@/core/infra";
116
115
  | --- | --- | --- | --- |
117
116
  | `@OutputCache` | controller action | `ICache` in-memory | the cache module's `ROUTE_MIDDLEWARE_COMPOSER` registration (automatic) |
118
117
  | `@OutputRedisCache` | controller action | `IDistributedCache` | the same + `redisConnect(config, { cache: "distributed" })` in Infra |
119
- | `@Cacheable` | service method | `ICache` in-memory | `cachedSingleton` / `cachedScoped` (or the auto-hook) |
120
- | `@CacheableRedis` | service method | `IDistributedCache` | `cachedSingleton` / `cachedScoped` + `redisConnect(config, { cache: "distributed" })` in Infra |
118
+ | `@Cacheable` | service method | `ICache` in-memory | `singleton` / `scoped` (the cache hook wraps it) |
119
+ | `@CacheableRedis` | service method | `IDistributedCache` | `singleton` / `scoped` + `redisConnect(config, { cache: "distributed" })` in Infra |
121
120
 
122
121
  **Important:** put decorators on the **implementation class**, not on a TypeScript interface. DI registers the interface token → the proxy wraps the implementation.
123
122
 
@@ -161,7 +160,7 @@ flowchart TB
161
160
  ### 3.1. Minimum: in-memory output + method cache
162
161
 
163
162
  ```ts
164
- import { Cacheable, OutputCache, cachedScoped, memory } from "@/core/cache";
163
+ import { Cacheable, OutputCache, memory } from "@/core/cache";
165
164
  import { runApp } from "@/core/app";
166
165
 
167
166
  await runApp(AppModule, {
@@ -195,7 +194,7 @@ class UserService implements IUserStore {
195
194
 
196
195
  // UsersModule:
197
196
  providers: [
198
- cachedScoped(IUserStore, UserService, [repositoryFor(User)]),
197
+ scoped(IUserStore, UserService, [repositoryFor(User)]),
199
198
  ],
200
199
  ```
201
200
 
@@ -232,7 +231,7 @@ class ProductService {
232
231
 
233
232
  // The same provider as for memory: the distributed level turns on
234
233
  // automatically when Infra publishes the registry of distributed stores in DI.
235
- cachedScoped(IProductService, ProductService, deps);
234
+ scoped(IProductService, ProductService, deps);
236
235
  ```
237
236
 
238
237
  ---
@@ -620,32 +619,27 @@ class UserService implements IUserStore {
620
619
 
621
620
  ---
622
621
 
623
- ## 10. DI providers: `cachedSingleton` / `cachedScoped` (+ auto-hook)
622
+ ## 10. DI registration: `singleton` / `scoped` with the cache hook
624
623
 
625
- One provider covers both levels. `@Cacheable` → the in-memory `ICache`; `@CacheableRedis` →
626
- the distributed `DISTRIBUTED_SERVICE_CACHE`, **automatically** if the registry is
627
- registered through Infra/DI. There are no separate `redisCached*` / `hybridCached*` anymore.
624
+ `memory()` registers a hook on the core `singleton()` / `scoped()` providers: if a class
625
+ carries `@Cacheable` / `@CacheableRedis`, the regular registration is wrapped with a
626
+ caching proxy. One proxy routes `@Cacheable` to the in-memory `ICache` and
627
+ `@CacheableRedis` to the distributed `DISTRIBUTED_SERVICE_CACHE`. The former
628
+ `cachedSingleton` / `cachedScoped` helpers were removed in 0.98.34: they did the same.
628
629
 
629
- | Provider | Decorators | DI dependencies | Scope |
630
- | --- | --- | --- | --- |
631
- | `cachedSingleton` | `@Cacheable` and/or `@CacheableRedis` | `ICache`, `CACHE_POLICIES`, (optional) `DISTRIBUTED_SERVICE_CACHE` | singleton |
632
- | `cachedScoped` | `@Cacheable` and/or `@CacheableRedis` | the same | scoped |
630
+ The hook checks the lifetime at container build (0.98.34):
633
631
 
634
- Removed aliases: `src/bazis/core/cache/COMPATIBILITY.md`.
632
+ | Lifetime | Rule |
633
+ | --- | --- |
634
+ | `singleton` | Any declaration; the automatic key works across calls |
635
+ | `scoped` | Every cached method needs an explicit `key` (or a named `policy`): the automatic key belongs to the instance, so it would cache within one request only. Otherwise `CacheDeclarationError` at startup |
636
+ | `transient` | Refused with `CacheDeclarationError`: a new instance per injection never reuses its cache |
635
637
 
636
638
  ```ts
637
639
  // Interface token ← proxy ← implementation class with decorators.
638
- // Works for both @Cacheable and @CacheableRedis (if the backend is configured).
639
- cachedScoped(IUserStore, UserService, [repositoryFor(User)]);
640
+ scoped(IUserStore, UserService);
640
641
  ```
641
642
 
642
- ### 10.0. Out of the box (auto-hook)
643
-
644
- `memory()` registers a hook on the core `singleton()` / `scoped()` providers: if a class
645
- carries `@Cacheable` / `@CacheableRedis`, the regular registration is **automatically**
646
- wrapped with a caching proxy. An explicit `cachedScoped` is needed only when you want to
647
- be completely explicit.
648
-
649
643
  ### 10.1. Interface vs implementation: a common question
650
644
 
651
645
  ```ts
@@ -659,7 +653,7 @@ class UserService implements IUserStore {
659
653
  }
660
654
 
661
655
  // ✅ correct
662
- cachedScoped(IUserStore, UserService, deps);
656
+ scoped(IUserStore, UserService, deps);
663
657
 
664
658
  // ❌ a decorator on an interface does not work
665
659
  // With memory() connected, a plain singleton(IUserStore, UserService)
@@ -674,7 +668,7 @@ or a root import of `memory()`. A feature module registers only its own service:
674
668
  ```ts
675
669
  @Module({
676
670
  imports: [UsersDataModule],
677
- providers: [cachedScoped(IUserStore, UserService, deps)],
671
+ providers: [scoped(IUserStore, UserService, deps)],
678
672
  })
679
673
  class UsersModule {}
680
674
  ```
@@ -1066,7 +1060,7 @@ class UserService implements IUserStore {
1066
1060
 
1067
1061
  // UsersModule:
1068
1062
  imports: [UsersDataModule],
1069
- providers: [cachedScoped(IUserStore, UserService, deps)],
1063
+ providers: [scoped(IUserStore, UserService, deps)],
1070
1064
  ```
1071
1065
 
1072
1066
  `@CacheableRedis.findByName` uses the distributed level automatically when the backend is
@@ -1174,7 +1168,7 @@ Checklist:
1174
1168
 
1175
1169
  1. The decorator is on the **class `UserService`**, not on `interface IUserStore`.
1176
1170
  2. The root app installs `cache: memory()` or imports `memory()` (auto-hook + `ICache`, `CACHE_POLICIES`).
1177
- 3. The registration goes through `singleton`/`scoped` (the auto-hook wraps it) or explicitly `cachedScoped`/`cachedSingleton`.
1171
+ 3. The registration goes through `singleton`/`scoped` (the cache hook wraps it); a scoped one needs an explicit `key`.
1178
1172
  4. The options have **`seconds`** (inline or in the `policy`).
1179
1173
  5. For `@CacheableRedis`, infra connects `redisConnect(redisConfig, { cache: "distributed" })`, otherwise the call throws.
1180
1174
 
@@ -1190,7 +1184,7 @@ Checklist:
1190
1184
  async byId(id: number) { ... }
1191
1185
 
1192
1186
  // UsersModule.ts: registration on the interface token
1193
- cachedScoped(IUserStore, UserService, deps);
1187
+ scoped(IUserStore, UserService, deps);
1194
1188
  ```
1195
1189
 
1196
1190
  The controller gets the proxy registered as `IUserStore`. The metadata is read from the `UserService` class (`Symbol.metadata`), not from the interface.
@@ -1242,28 +1236,16 @@ Technically yes, but **do not**: a startup warning, and Redis wins. Pick one sto
1242
1236
 
1243
1237
  ### Can `@Cacheable` and `@CacheableRedis` be on one method?
1244
1238
 
1245
- **No**: a runtime error. On **different** methods of one class use a regular `cachedScoped` (one proxy routes memory and distributed).
1239
+ **No**: a runtime error. On **different** methods of one class use a regular `scoped` registration (one proxy routes memory and distributed).
1246
1240
 
1247
1241
  ---
1248
1242
 
1249
- ### `cachedSingleton` or `cachedScoped`?
1250
-
1251
- | | singleton | scoped |
1252
- | --- | --- | --- |
1253
- | Proxy instance | one per DI registration in the container | one per DI registration in the scope |
1254
- | `@Cacheable` cache | the shared ICache store, automatic keys are isolated per instance | the same ICache store, automatic keys are isolated per instance |
1255
- | ORM `DbContext` | ⚠️ careful | ✅ the typical case |
1256
-
1257
- A `UserService` with an ORM → **`cachedScoped`**. A stateless read-only service without scoped deps → `cachedSingleton` is fine.
1258
- An explicit `key` in `cachedScoped` and `cachedSingleton` is a sharing contract: include the
1259
- tenant/user in it if the result is personalized. Without an explicit `key` the framework
1260
- isolates the automatic key per instance, so hidden constructor state does not leak into the
1261
- response of another registration or request. This also applies to `@CacheableRedis`:
1262
- sharing between processes and reuse after a restart need an explicit application `key`.
1263
- DI creates the object with a regular class provider and then applies the proxy;
1264
- constructor deps and lifecycle are not bypassed.
1243
+ ### `singleton` or `scoped`?
1265
1244
 
1266
- ---
1245
+ A `UserService` with an ORM is **`scoped`**: give every cached method an explicit `key`
1246
+ that includes the user or tenant when the result depends on them. A stateless read-only
1247
+ service without scoped dependencies can be a `singleton`; its automatic keys work across
1248
+ calls.
1267
1249
 
1268
1250
  ### Is Redis needed for the application to work?
1269
1251
 
@@ -12,8 +12,9 @@ type ClassOrMethodDecorator = (
12
12
  * Caches the return value of a service/provider method in {@link ICache}.
13
13
  *
14
14
  * Mirrors Spring / FusionCache `@Cacheable`. Put the decorator on the
15
- * **implementation class**, then register via {@link cachedSingleton} or
16
- * {@link cachedScoped} so DI returns a caching proxy for the interface token.
15
+ * **implementation class** and register it as usual (`scoped`, `singleton`):
16
+ * with the cache module installed, DI returns a caching proxy. A scoped
17
+ * service needs an explicit `key`; a transient one cannot cache.
17
18
  *
18
19
  * ```ts
19
20
  * class UserService implements IUserStore {
@@ -21,7 +22,7 @@ type ClassOrMethodDecorator = (
21
22
  * async byId(id: number) { ... }
22
23
  * }
23
24
  *
24
- * cachedScoped(IUserStore, UserService, [repositoryFor(User)])
25
+ * scoped(IUserStore, UserService)
25
26
  * ```
26
27
  */
27
28
  export function Cacheable(options: CacheableOptions): ClassOrMethodDecorator {
@@ -12,9 +12,8 @@ type ClassOrMethodDecorator = (
12
12
  * Shared service method cache backed by the distributed tier (multi-instance safe).
13
13
  *
14
14
  * Requires a distributed backend: add `redisConnect(redisConfig, { cache: "distributed" })`
15
- * to your `@Infra` manifest. Register the class with the usual `cachedScoped` /
16
- * `cachedSingleton` (or just `scoped` / `singleton` — the auto-hook wraps it). The distributed
17
- * tier activates automatically when a backend is configured.
15
+ * to your `@Infra` manifest. Register the class as usual (`scoped` / `singleton`):
16
+ * the cache module wraps it. Values must be plain JSON.
18
17
  *
19
18
  * ```ts
20
19
  * class UserService implements IUserStore {
@@ -22,7 +21,7 @@ type ClassOrMethodDecorator = (
22
21
  * async byId(id: number) { ... }
23
22
  * }
24
23
  *
25
- * cachedScoped(IUserStore, UserService, [repositoryFor(User)]);
24
+ * scoped(IUserStore, UserService);
26
25
  * ```
27
26
  */
28
27
  export function CacheableRedis(options: CacheableRedisOptions): ClassOrMethodDecorator {
@@ -1,4 +1,5 @@
1
1
  import type { Class, ClassProviderHook, Token } from "../../di";
2
+ import { CacheDeclarationError } from "../errors/CacheError";
2
3
  import { cacheableMetaOf, hasCacheableMethods } from "../decorators/cacheableMetadata";
3
4
  import { cacheableRedisMetaOf, hasCacheableRedisMethods } from "../decorators/cacheableRedisMetadata";
4
5
  import { cachedScoped, cachedSingleton } from "../providers/cachedProviders";
@@ -16,6 +17,7 @@ export const cacheableClassProviderHook: ClassProviderHook = ({ lifetime, provid
16
17
  }
17
18
  const token = provide as Token<object>;
18
19
  const impl = useClass as Class<object>;
20
+ assertCacheableLifetime(impl, lifetime);
19
21
  if (lifetime === "scoped") {
20
22
  return cachedScoped(token, impl, deps);
21
23
  }
@@ -24,3 +26,32 @@ export const cacheableClassProviderHook: ClassProviderHook = ({ lifetime, provid
24
26
  }
25
27
  return undefined;
26
28
  };
29
+
30
+ interface Declared { readonly key?: unknown; readonly policy?: unknown }
31
+
32
+ /**
33
+ * A cache that can never hit is refused at startup, as an invalid registration
34
+ * (a DI error: the start prints it as a configuration error). The automatic key belongs
35
+ * to the service instance (a scoped result may depend on the request's user),
36
+ * so without an explicit `key` a scoped service caches only within one request,
37
+ * and a transient one — never.
38
+ */
39
+ function assertCacheableLifetime(impl: Class<object>, lifetime: string): void {
40
+ const declarations: { decorator: string; classLevel?: Declared; methods: ReadonlyMap<string | symbol, Declared> }[] = [];
41
+ const memory = cacheableMetaOf(impl);
42
+ if (hasCacheableMethods(memory)) declarations.push({ decorator: "@Cacheable", classLevel: memory!.cacheable, methods: memory!.methods });
43
+ const shared = cacheableRedisMetaOf(impl);
44
+ if (hasCacheableRedisMethods(shared)) declarations.push({ decorator: "@CacheableRedis", classLevel: shared!.cacheableRedis, methods: shared!.methods });
45
+ for (const { decorator, classLevel, methods } of declarations) {
46
+ if (lifetime === "transient") {
47
+ throw new CacheDeclarationError(`${impl.name} uses ${decorator} but is registered transient: a new instance for every injection never reuses its cache. Register it as singleton or scoped.`);
48
+ }
49
+ if (lifetime !== "scoped") continue;
50
+ const names = methods.size > 0 ? [...methods.keys()] : ["(every method)"];
51
+ for (const name of names) {
52
+ const declared = { ...classLevel, ...(typeof name === "string" && name === "(every method)" ? {} : methods.get(name)) };
53
+ if (declared.key !== undefined || declared.policy !== undefined) continue;
54
+ throw new CacheDeclarationError(`${impl.name}.${String(name)}: ${decorator} without key on a scoped service caches only within one request (the automatic key belongs to the instance). Set key — include the user or tenant when the result depends on them — or register ${impl.name} as a singleton.`);
55
+ }
56
+ }
57
+ }
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { CacheValueError } from "../errors/CacheError";
2
+ import { CacheUnavailableError, CacheValueError } from "../errors/CacheError";
3
3
  import { assertCacheKey, measureStringBytes } from "../internal/CacheKeyGuard";
4
4
  import type { CacheCodec } from "./CacheCodec";
5
5
  import type {
@@ -36,8 +36,12 @@ export interface DistributedCacheConfig {
36
36
  readonly maxValueBytes?: number;
37
37
  /** Poll interval (ms) while waiting for a peer worker to fill the cache. */
38
38
  readonly pollIntervalMs?: number;
39
+ /** Bound of one store operation (ms); a hung store must not hang the caller. Default 1000. */
40
+ readonly operationTimeoutMs?: number;
39
41
  }
40
42
 
43
+ const DEFAULT_OPERATION_TIMEOUT_MS = 1000;
44
+
41
45
  /**
42
46
  * Backend-agnostic distributed cache. Owns all caching policy and delegates raw
43
47
  * I/O to a {@link DistributedCacheDriver}. One instance per (connection × namespace).
@@ -52,6 +56,9 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
52
56
  private readonly fullPrefix: string;
53
57
  private readonly maxKeyLength: number;
54
58
  private readonly pollIntervalMs: number;
59
+ private readonly operationTimeoutMs: number;
60
+ /** The driver with every operation bounded by {@link io}. */
61
+ private readonly ioCall: DistributedCacheDriver;
55
62
 
56
63
  public readonly connectionName: string;
57
64
  public readonly keyPrefix: string;
@@ -71,6 +78,9 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
71
78
  if (snapshot.maxValueBytes !== undefined && (!Number.isInteger(snapshot.maxValueBytes) || snapshot.maxValueBytes <= 0)) {
72
79
  throw new CacheValueError("maxValueBytes must be a positive integer");
73
80
  }
81
+ if (snapshot.operationTimeoutMs !== undefined && (!Number.isSafeInteger(snapshot.operationTimeoutMs) || snapshot.operationTimeoutMs <= 0 || snapshot.operationTimeoutMs > 2_147_483_647)) {
82
+ throw new CacheValueError("operationTimeoutMs must be a positive integer of milliseconds");
83
+ }
74
84
  if (snapshot.pollIntervalMs !== undefined && (!Number.isFinite(snapshot.pollIntervalMs) || snapshot.pollIntervalMs <= 0)) {
75
85
  throw new CacheValueError("pollIntervalMs must be a positive number");
76
86
  }
@@ -80,12 +90,40 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
80
90
  this.fullPrefix = `${snapshot.keyPrefix}${snapshot.namespace ?? ""}`;
81
91
  this.maxKeyLength = snapshot.maxKeyLength ?? DEFAULT_DISTRIBUTED_MAX_KEY_LENGTH;
82
92
  this.pollIntervalMs = snapshot.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
93
+ this.operationTimeoutMs = snapshot.operationTimeoutMs ?? DEFAULT_OPERATION_TIMEOUT_MS;
94
+ this.ioCall = new Proxy(driver, {
95
+ get: (target, property) => {
96
+ const member = (target as unknown as Record<PropertyKey, unknown>)[property];
97
+ return typeof member === "function"
98
+ ? (...args: unknown[]) => this.io(() => (member as (...values: unknown[]) => Promise<unknown>).apply(target, args))
99
+ : member;
100
+ },
101
+ });
102
+ }
103
+
104
+ /**
105
+ * One store operation, bounded by `operationTimeoutMs`. A timeout or a driver
106
+ * failure becomes {@link CacheUnavailableError}; the native operation is not
107
+ * cancelled (the driver has no cancellation), only no longer awaited.
108
+ */
109
+ private io<T>(work: () => Promise<T>): Promise<T> {
110
+ return new Promise<T>((resolve, reject) => {
111
+ const timer = setTimeout(() => reject(new CacheUnavailableError(
112
+ `Distributed cache "${this.connectionName}" did not answer within ${this.operationTimeoutMs} ms.`,
113
+ )), this.operationTimeoutMs);
114
+ let started: Promise<T>;
115
+ try { started = work(); } catch (error) { clearTimeout(timer); reject(unavailable(this.connectionName, error)); return; }
116
+ started.then(
117
+ (value) => { clearTimeout(timer); resolve(value); },
118
+ (error: unknown) => { clearTimeout(timer); reject(unavailable(this.connectionName, error)); },
119
+ );
120
+ });
83
121
  }
84
122
 
85
123
  public async get<T = TValue>(key: string): Promise<T | undefined> {
86
124
  assertCacheKey(key, this.maxKeyLength);
87
125
  const storageKey = this.storageKey(key);
88
- const raw = await this.driver.read(storageKey);
126
+ const raw = await this.ioCall.read(storageKey);
89
127
  if (raw === null) {
90
128
  return undefined;
91
129
  }
@@ -93,7 +131,7 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
93
131
  return this.codec.deserialize(decodeEnvelope(raw).payload) as unknown as T;
94
132
  } catch {
95
133
  try {
96
- await this.driver.deleteIfValue(storageKey, raw);
134
+ await this.ioCall.deleteIfValue(storageKey, raw);
97
135
  } catch {
98
136
  // A corrupt cache entry is still a miss when backend cleanup fails.
99
137
  // Do not turn best-effort cache maintenance into a request failure.
@@ -108,7 +146,14 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
108
146
  options: DistributedCacheSetOptions,
109
147
  ): Promise<T | undefined> {
110
148
  const normalizedOptions = this.normalizeSetOptions(options);
111
- const hit = await this.get<T>(key);
149
+ let hit: T | undefined;
150
+ try {
151
+ hit = await this.get<T>(key);
152
+ } catch (error) {
153
+ // The cache is optional: an unavailable store means "compute it now".
154
+ if (error instanceof CacheUnavailableError) return factory();
155
+ throw error;
156
+ }
112
157
  if (hit !== undefined) {
113
158
  return hit;
114
159
  }
@@ -132,7 +177,7 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
132
177
  public async remove(key: string): Promise<boolean> {
133
178
  assertCacheKey(key, this.maxKeyLength);
134
179
  this.inFlight.delete(key);
135
- return this.driver.invalidate(this.entryVersionKey(key), this.storageKey(key), this.lockKeyFor(key));
180
+ return this.ioCall.invalidate(this.entryVersionKey(key), this.storageKey(key), this.lockKeyFor(key));
136
181
  }
137
182
 
138
183
  public async evictByTag(tag: string): Promise<number> {
@@ -142,8 +187,8 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
142
187
  }
143
188
  const versionKey = this.tagVersionKey(tag);
144
189
  const pendingKey = this.tagPendingKey(tag);
145
- await this.driver.advanceTagGeneration(versionKey, pendingKey);
146
- const generations = [...await this.driver.tagMembers(pendingKey)]
190
+ await this.ioCall.advanceTagGeneration(versionKey, pendingKey);
191
+ const generations = [...await this.ioCall.tagMembers(pendingKey)]
147
192
  .filter((value) => /^\d+$/.test(value))
148
193
  .sort((left, right) => Number(left) - Number(right));
149
194
  let removed = 0;
@@ -155,7 +200,7 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
155
200
 
156
201
  private async evictTagGeneration(tag: string, generation: string, pendingKey: string): Promise<number> {
157
202
  const tagKey = this.tagKey(tag, generation);
158
- const members = await this.driver.tagMembers(tagKey);
203
+ const members = await this.ioCall.tagMembers(tagKey);
159
204
  let removed = 0;
160
205
  for (const member of members) {
161
206
  const parsed = decodeTagMember(member);
@@ -165,19 +210,19 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
165
210
  if (!parsed.storageKey.startsWith(`${this.fullPrefix}entry:`)) {
166
211
  continue;
167
212
  }
168
- const raw = await this.driver.read(parsed.storageKey);
213
+ const raw = await this.ioCall.read(parsed.storageKey);
169
214
  if (raw === null) {
170
215
  continue;
171
216
  }
172
217
  if (decodeEnvelope(raw).token !== parsed.token) {
173
218
  continue;
174
219
  }
175
- if (await this.driver.deleteIfValue(parsed.storageKey, raw)) {
220
+ if (await this.ioCall.deleteIfValue(parsed.storageKey, raw)) {
176
221
  removed += 1;
177
222
  }
178
223
  }
179
- await this.driver.delete([tagKey]);
180
- await this.driver.removeTagMembers(pendingKey, [generation]);
224
+ await this.ioCall.delete([tagKey]);
225
+ await this.ioCall.removeTagMembers(pendingKey, [generation]);
181
226
  return removed;
182
227
  }
183
228
 
@@ -215,9 +260,21 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
215
260
  const lockTtl = Math.max(1, Math.floor(options.lockSeconds ?? this.config.defaultLockSeconds));
216
261
  const token = randomUUID();
217
262
 
218
- const acquired = await this.driver.acquireLock(lockKey, token, lockTtl);
263
+ let acquired: boolean;
264
+ try {
265
+ acquired = await this.ioCall.acquireLock(lockKey, token, lockTtl);
266
+ } catch (error) {
267
+ if (error instanceof CacheUnavailableError) return factory();
268
+ throw error;
269
+ }
219
270
  if (!acquired) {
220
- const peerValue = await this.waitForPeerResult<T>(key, lockKey, lockTtl);
271
+ let peerValue: T | undefined;
272
+ try {
273
+ peerValue = await this.waitForPeerResult<T>(key, lockKey, lockTtl);
274
+ } catch (error) {
275
+ if (error instanceof CacheUnavailableError) return factory();
276
+ throw error;
277
+ }
221
278
  if (peerValue !== undefined) return peerValue;
222
279
  if (contentionAttempt < 2) {
223
280
  return this.runLockedFactory(key, factory, options, contentionAttempt + 1);
@@ -228,11 +285,18 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
228
285
  }
229
286
 
230
287
  try {
231
- const again = await this.get<T>(key);
232
- if (again !== undefined) {
233
- return again;
288
+ let versionChecks: readonly DistributedCacheVersionCheck[];
289
+ try {
290
+ const again = await this.get<T>(key);
291
+ if (again !== undefined) {
292
+ return again;
293
+ }
294
+ versionChecks = await this.captureVersions(key, options.tags);
295
+ } catch (error) {
296
+ // The store went away under the lock: compute, but do not write.
297
+ if (error instanceof CacheUnavailableError) return await factory();
298
+ throw error;
234
299
  }
235
- const versionChecks = await this.captureVersions(key, options.tags);
236
300
  const value = await factory();
237
301
  if (value !== undefined) {
238
302
  await this.trySet(key, value, options, lockKey, token, versionChecks);
@@ -243,7 +307,7 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
243
307
  // Unlock is best effort: a transport error must not replace a fresh
244
308
  // service result or the factory's original exception. The lock still
245
309
  // has a bounded TTL, so another worker eventually makes progress.
246
- await this.driver.releaseLock(lockKey, token, this.entryVersionKey(key));
310
+ await this.ioCall.releaseLock(lockKey, token, this.entryVersionKey(key));
247
311
  } catch {
248
312
  // Intentionally preserve the primary outcome.
249
313
  }
@@ -257,7 +321,7 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
257
321
  if (hit !== undefined) {
258
322
  return hit;
259
323
  }
260
- if (await this.driver.read(lockKey) === null) {
324
+ if (await this.ioCall.read(lockKey) === null) {
261
325
  break;
262
326
  }
263
327
  await Bun.sleep(this.pollIntervalMs);
@@ -273,8 +337,11 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
273
337
  lockToken: string,
274
338
  versionChecks: readonly DistributedCacheVersionCheck[],
275
339
  ): Promise<void> {
340
+ // A value the codec refuses is a bug of the cached method, not a store
341
+ // outage: it surfaces instead of silently never being cached.
342
+ const serialized = this.codec.serialize(value as unknown as TValue);
276
343
  try {
277
- await this.write(key, value as unknown as TValue, options, lockKey, lockToken, versionChecks);
344
+ await this.write(key, serialized, options, lockKey, lockToken, versionChecks);
278
345
  } catch {
279
346
  // Graceful degradation — the caller still receives the fresh value.
280
347
  }
@@ -282,14 +349,13 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
282
349
 
283
350
  private async write(
284
351
  key: string,
285
- value: TValue,
352
+ serialized: string,
286
353
  options: DistributedCacheSetOptions,
287
354
  lockKey: string,
288
355
  lockToken: string,
289
356
  versionChecks: readonly DistributedCacheVersionCheck[],
290
357
  ): Promise<void> {
291
358
  assertCacheKey(key, this.maxKeyLength);
292
- const serialized = this.codec.serialize(value);
293
359
 
294
360
  if (this.config.maxValueBytes !== undefined) {
295
361
  const bytes = measureStringBytes(serialized);
@@ -318,7 +384,7 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
318
384
  tags[index] = { key: tag.key, member: encodeTagMember(storageKey, writeToken) };
319
385
  }
320
386
  const envelope = encodeEnvelope(writeToken, serialized);
321
- await this.driver.writeIfLockOwner({
387
+ await this.ioCall.writeIfLockOwner({
322
388
  lockKey,
323
389
  lockToken,
324
390
  key: storageKey,
@@ -336,7 +402,7 @@ export class DistributedCache<TValue = unknown> implements IDistributedCache<TVa
336
402
  const keys = [this.entryVersionKey(key), ...(tags ?? []).map((tag) => this.tagVersionKey(tag))];
337
403
  return Promise.all(keys.map(async (versionKey) => ({
338
404
  key: versionKey,
339
- expected: await this.driver.read(versionKey),
405
+ expected: await this.ioCall.read(versionKey),
340
406
  })));
341
407
  }
342
408
 
@@ -401,3 +467,8 @@ function decodeTagMember(member: string): { readonly storageKey: string; readonl
401
467
  }
402
468
  return undefined;
403
469
  }
470
+
471
+ function unavailable(connection: string, error: unknown): CacheUnavailableError {
472
+ if (error instanceof CacheUnavailableError) return error;
473
+ return new CacheUnavailableError(`Distributed cache "${connection}" failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
474
+ }
@@ -1,3 +1,4 @@
1
+ import { createToken, type InjectionToken } from "../../di";
1
2
  import type { CacheSetOptions } from "../types/CacheOptions";
2
3
 
3
4
  /** Default named connection used when a decorator omits `connection`. */
@@ -38,3 +39,11 @@ export interface IDistributedCache<TValue = unknown> {
38
39
  /** Removes all entries carrying `tag`; returns the number removed. */
39
40
  evictByTag(tag: string): Promise<number>;
40
41
  }
42
+
43
+ /**
44
+ * The distributed cache by type: `constructor(private readonly shared: IDistributedCache)`.
45
+ * Registered by `redisConnect(config, { cache: "distributed" })` for its one
46
+ * connection; a custom backend with several connections keeps
47
+ * `DISTRIBUTED_SERVICE_CACHE.resolve(name)`.
48
+ */
49
+ export const IDistributedCache = createToken<IDistributedCache>("IDistributedCache") as InjectionToken<IDistributedCache<unknown>>;
@@ -1,12 +1,59 @@
1
1
  import type { CachedHttpPayload } from "../http/CachedHttpPayload";
2
2
  import type { CacheCodec } from "./CacheCodec";
3
+ import { CacheValueError } from "../errors/CacheError";
3
4
 
4
- /** JSON codec for arbitrary service values (`@Cacheable` distributed tier). */
5
+ /**
6
+ * JSON codec for service values (`@CacheableRedis`, `IDistributedCache`). It
7
+ * refuses a value that JSON would change on the way back — a Date comes back
8
+ * as a string, a Set or Map as `{}` — instead of returning altered data on a
9
+ * cache hit.
10
+ */
5
11
  export const jsonCacheCodec: CacheCodec<unknown> = {
6
- serialize: (value) => JSON.stringify(value),
12
+ serialize: (value) => {
13
+ const problem = notPlainJson(value, "value", new Set());
14
+ if (problem !== undefined) {
15
+ throw new CacheValueError(`Cache value is not plain JSON: ${problem}. Return plain data (an ISO string for a date, an array for a set), or cache it in memory with @Cacheable.`);
16
+ }
17
+ return JSON.stringify(value);
18
+ },
7
19
  deserialize: (raw) => JSON.parse(raw) as unknown,
8
20
  };
9
21
 
22
+ /** Where `value` stops surviving JSON unchanged, or undefined when it does. */
23
+ function notPlainJson(value: unknown, path: string, seen: Set<object>): string | undefined {
24
+ if (value === null || typeof value === "string" || typeof value === "boolean") return undefined;
25
+ if (typeof value === "number") return Number.isFinite(value) ? undefined : `${path} is ${String(value)} (JSON writes null)`;
26
+ if (typeof value === "bigint") return `${path} is a bigint`;
27
+ if (typeof value !== "object") return `${path} is a ${typeof value}`;
28
+ if (seen.has(value)) return `${path} is a circular reference`;
29
+ seen.add(value);
30
+ try {
31
+ if (Array.isArray(value)) {
32
+ for (let index = 0; index < value.length; index += 1) {
33
+ const item: unknown = value[index];
34
+ if (item === undefined) return `${path}[${index}] is undefined (JSON writes null)`;
35
+ const problem = notPlainJson(item, `${path}[${index}]`, seen);
36
+ if (problem !== undefined) return problem;
37
+ }
38
+ return undefined;
39
+ }
40
+ const prototype = Object.getPrototypeOf(value) as object | null;
41
+ if (prototype !== Object.prototype && prototype !== null) {
42
+ const name = (value as { constructor?: { name?: string } }).constructor?.name || "object with a prototype";
43
+ return `${path} is a ${name}${value instanceof Date ? " (JSON returns a string)" : value instanceof Map || value instanceof Set ? " (JSON returns {})" : ""}`;
44
+ }
45
+ for (const key of Object.keys(value)) {
46
+ const item = (value as Record<string, unknown>)[key];
47
+ if (item === undefined) continue; // dropped and read back as undefined
48
+ const problem = notPlainJson(item, `${path}.${key}`, seen);
49
+ if (problem !== undefined) return problem;
50
+ }
51
+ return undefined;
52
+ } finally {
53
+ seen.delete(value);
54
+ }
55
+ }
56
+
10
57
  interface SerializedHttpPayload {
11
58
  readonly status: number;
12
59
  readonly headers: Readonly<Record<string, string>>;
@@ -3,7 +3,7 @@ export { jsonCacheCodec, httpPayloadCacheCodec } from "./codecs";
3
3
  export {
4
4
  DEFAULT_CACHE_CONNECTION,
5
5
  type DistributedCacheSetOptions,
6
- type IDistributedCache,
6
+ IDistributedCache,
7
7
  } from "./IDistributedCache";
8
8
  export type {
9
9
  DistributedCacheDriver,