bazis 0.98.32 → 0.98.34

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.
@@ -56,9 +56,7 @@ export function buildModuleTemplates(
56
56
  }
57
57
 
58
58
  function moduleFile(n: ModuleNaming, profile: ModuleTemplateProfile): string {
59
- const cacheImport = profile === "full" ? "import { cachedScoped } from \"bazis/core/cache\";\n" : "";
60
59
  const moduleImport = "import { Module, scoped } from \"bazis/core/di\";";
61
- const providerFactory = profile === "full" ? "cachedScoped" : "scoped";
62
60
  const backgroundImport = profile === "full" ? `import { ${n.module}Reporter } from "./background/${n.module}.reporter";\n` : "";
63
61
  const aiImports = profile === "full"
64
62
  ? `import { ${n.module}AnalystAgent } from "./ai/agents/${n.module}Analyst.agent";
@@ -72,7 +70,7 @@ import { ${n.module}SummaryTool } from "./ai/tools/${n.module}Summary.tool";
72
70
  `
73
71
  : "";
74
72
 
75
- return `${cacheImport}${moduleImport}
73
+ return `${moduleImport}
76
74
  ${aiImports}${backgroundImport}import { ${n.module}Controller } from "./http/${n.module}.controller";
77
75
  import { ${n.entity} } from "./model/${n.module}.model";
78
76
  import { ${n.module}DbContext } from "./model/${n.module}.dbContext";
@@ -86,7 +84,7 @@ import { ${n.module}Service } from "./services/${n.module}.service";
86
84
  },
87
85
  controllers: [${n.module}Controller],
88
86
  providers: [
89
- ${providerFactory}(I${n.module}Service, ${n.module}Service),
87
+ scoped(I${n.module}Service, ${n.module}Service),
90
88
  ],
91
89
  ${backgroundLine}${aiLines} exports: [I${n.module}Service],
92
90
  })
@@ -78,7 +78,7 @@ exports: [I${m}Service]. TypeScript contract: services/I${e}.service.ts;
78
78
  HTTP access is defined by the controller and the host policy, separately from DI exports.
79
79
  I${m}Service → ${m}Service, scoped lifetime. The context and DbSet belong to the ORM.
80
80
  ${profile === "full" ? `${m}SummaryTool is listed in tools: [${m}SummaryTool]; the framework registers it as scoped, the tool is not exported.
81
- The service uses cachedScoped; ICache comes from the host cacheModule.
81
+ The service is scoped; its keyed @Cacheable works when the host installs the cache (runApp cache: memory()).
82
82
  The background service gets its own scope per tick and disposes it in finally.
83
83
  The host also provides auth from src/app/modules/auth (TokenKind.Admin/Client),
84
84
  AgentRuntime and the reasoning modelProfile. The CLI does not create them.
@@ -1,4 +1,4 @@
1
- import type { HostedService, HostedServiceDiagnostics } from "../di";
1
+ import type { HostedService, HostedServiceDiagnostics, HostedServiceHealth } from "../di";
2
2
  import { backgroundOptionsOf } from "./decorator";
3
3
  import { delay } from "./delay";
4
4
  import { redactSensitive } from "../../library/redaction";
@@ -56,6 +56,8 @@ export abstract class BackgroundService implements HostedService {
56
56
 
57
57
  /** The application logger, passed by the kernel; console until then. */
58
58
  private diagnostics?: HostedServiceDiagnostics;
59
+ /** Set when the service stopped for good after a crash: `/health` reports it. */
60
+ private terminated?: string;
59
61
 
60
62
  public constructor(options?: BackgroundServiceOptions) {
61
63
  // Explicit `super(...)` options win over `@Background({...})` metadata.
@@ -68,6 +70,15 @@ export abstract class BackgroundService implements HostedService {
68
70
  /** The background workload. Must observe `signal` for cooperative shutdown. */
69
71
  protected abstract execute(signal: AbortSignal): void | Promise<void>;
70
72
 
73
+ /**
74
+ * Unhealthy once the service stopped for good: it crashed without a restart
75
+ * policy, or used up its restarts. A pause before a restart, a tick failure
76
+ * of a periodic service and a normal end of `execute` stay healthy.
77
+ */
78
+ public checkHealth(): HostedServiceHealth {
79
+ return this.terminated === undefined ? { healthy: true } : { healthy: false, details: this.terminated };
80
+ }
81
+
71
82
  public useDiagnostics(diagnostics: HostedServiceDiagnostics): void {
72
83
  this.diagnostics = diagnostics;
73
84
  }
@@ -119,9 +130,11 @@ export abstract class BackgroundService implements HostedService {
119
130
  this.reportError(error, restarts);
120
131
  if (restarts >= maxRestarts) {
121
132
  // Without this line the service would just go quiet.
122
- report(this.diagnostics, "error", this.constructor.name, maxRestarts === 0
133
+ const stopped = maxRestarts === 0
123
134
  ? "stopped after a crash and will not run again (no restart policy)"
124
- : `stopped after ${maxRestarts} restart${maxRestarts === 1 ? "" : "s"} and will not run again`, { restarts });
135
+ : `stopped after ${maxRestarts} restart${maxRestarts === 1 ? "" : "s"} and will not run again`;
136
+ report(this.diagnostics, "error", this.constructor.name, stopped, { restarts });
137
+ this.terminated = `${stopped}: ${error instanceof Error ? error.message : String(error)}`;
125
138
  return;
126
139
  }
127
140
  restarts += 1;
@@ -26,7 +26,8 @@ interface BackgroundMetadataCarrier {
26
26
  * ```
27
27
  *
28
28
  * Options passed explicitly to `super(...)` take precedence over the decorator.
29
- * Registration stays explicit — list the class in `backgroundModule({ services })`.
29
+ * Registration stays explicit — list the class in `background` of its module:
30
+ * `@Module({ background: [Heartbeat] })`.
30
31
  */
31
32
  export function Background(options: BackgroundDecoratorOptions = {}) {
32
33
  return (_value: abstract new (...args: never[]) => unknown, context: ClassDecoratorContext): void => {
@@ -10,6 +10,5 @@ export {
10
10
  type BackgroundServiceOptions,
11
11
  type PeriodicBackgroundServiceOptions,
12
12
  } from "./BackgroundService";
13
- export { backgroundModule, type BackgroundModuleConfig } from "./backgroundModule";
14
13
  export { Background, backgroundOptionsOf, type BackgroundDecoratorOptions } from "./decorator";
15
14
  export { delay } from "./delay";
@@ -1,12 +1,12 @@
1
1
  # Cache Compatibility
2
2
 
3
- The aliases below were removed in 0.96.1, before the first `bazis` npm release,
4
- so no published version ever contained them. Use the replacement.
3
+ The names below were removed; use the replacement. The `cached*` helpers were
4
+ public until 0.98.34, the others were removed in 0.96.1 before the first npm release.
5
5
 
6
6
  | Removed name | Kind | Replacement |
7
7
  | --- | --- | --- |
8
- | `autoCachedScoped` | public alias | `cachedScoped` |
9
- | `autoCachedSingleton` | public alias | `cachedSingleton` |
8
+ | `autoCachedScoped`, `cachedScoped` | public helpers, removed in 0.98.34 | `scoped(...)`: the cache hook wraps it |
9
+ | `autoCachedSingleton`, `cachedSingleton` | public helpers, removed in 0.98.34 | `singleton(...)` |
10
10
  | `warnInsecureOutputCacheRoute` | public alias | `guardInsecureOutputCacheRoute` |
11
11
  | `warnOnAuthorizedRoutesWithoutVaryByUser` | config switch | `insecureAuthorizedRouteBehavior` (`false` was `"ignore"`) |
12
12
  | `DEFAULT_CACHEABLE_METHODS` | internal alias | `DEFAULT_OUTPUT_CACHE_METHODS` |
@@ -79,5 +79,5 @@ The `@/core/cache` core does not import Redis at all, only the
79
79
 
80
80
  ## Compatibility
81
81
 
82
- DI helpers are `cachedScoped` and `cachedSingleton`. Former aliases were
83
- removed before the first npm release; see [COMPATIBILITY.md](./COMPATIBILITY.md).
82
+ Cached services are registered with the regular `scoped` / `singleton`; the cache
83
+ hook wraps them. Removed names are listed in [COMPATIBILITY.md](./COMPATIBILITY.md).
@@ -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,
@@ -1,7 +1,8 @@
1
+ import { DiError } from "../../di";
1
2
  /** Base error of the cache module. */
2
3
  export class CacheError extends Error {
3
- public constructor(message: string) {
4
- super(message);
4
+ public constructor(message: string, options?: ErrorOptions) {
5
+ super(message, options);
5
6
  this.name = new.target.name;
6
7
  }
7
8
  }
@@ -12,5 +13,19 @@ export class CacheKeyError extends CacheError {}
12
13
  /** Invalid value (size limit exceeded and the like). */
13
14
  export class CacheValueError extends CacheError {}
14
15
 
16
+ /**
17
+ * The distributed store did not answer in time or failed. On the cache-aside
18
+ * path (`getOrCreateAsync`, `@CacheableRedis`) the value is computed without
19
+ * the cache; explicit operations (`get`, `remove`, `evictByTag`) throw it.
20
+ */
21
+ export class CacheUnavailableError extends CacheError {}
22
+
15
23
  /** The limit of unfinished factories is exhausted; the new factory was not started. */
16
24
  export class CacheCapacityError extends CacheError {}
25
+
26
+ /**
27
+ * A cache declaration that can never work, found while the container is built
28
+ * (a cached scoped method without a key, a cached transient service). A DI
29
+ * error, so the start prints it as a configuration error.
30
+ */
31
+ export class CacheDeclarationError extends DiError {}
@@ -1,5 +1,5 @@
1
1
  import type { HttpMiddleware } from "../../http";
2
- import { CacheError } from "../errors/CacheError";
2
+ import { CacheError, CacheUnavailableError } from "../errors/CacheError";
3
3
  import { DISTRIBUTED_OUTPUT_CACHE } from "../tokens/DISTRIBUTED_CACHE";
4
4
  import {
5
5
  DEFAULT_OUTPUT_CACHE_METHODS,
@@ -14,6 +14,7 @@ import {
14
14
  resolveCachedHttpPayloadReadOptions,
15
15
  responseAllowsServerCache,
16
16
  responseToCachedPayload,
17
+ type CachedHttpPayload,
17
18
  } from "./CachedHttpPayload";
18
19
 
19
20
  export interface OutputRedisCacheMiddlewareOptions {
@@ -81,7 +82,15 @@ export function outputRedisCacheMiddleware(options: OutputRedisCacheMiddlewareOp
81
82
  }
82
83
 
83
84
  const key = buildRedisOutputCacheKey(ctx, routeName, config);
84
- const cached = await cache.get(key);
85
+ let cached: CachedHttpPayload | undefined;
86
+ try {
87
+ cached = await cache.get(key);
88
+ } catch (error) {
89
+ // The cache is optional: an unavailable store serves the route uncached.
90
+ if (!(error instanceof CacheUnavailableError)) throw error;
91
+ await next();
92
+ return;
93
+ }
85
94
  if (cached !== undefined) {
86
95
  ctx.response = applyClientCacheHeaders(cachedPayloadToResponse(cached, { hit: true }), clientCache);
87
96
  return;
@@ -51,7 +51,7 @@ export {
51
51
  type DistributedCacheVersionCheck,
52
52
  type DistributedCachePing,
53
53
  type DistributedCacheSetOptions,
54
- type IDistributedCache,
54
+ IDistributedCache,
55
55
  } from "./distributed";
56
56
  export {
57
57
  DISTRIBUTED_CACHE_BACKEND,
@@ -66,7 +66,7 @@ export {
66
66
  type CacheModuleConfig,
67
67
  type CacheOutputCacheOptions,
68
68
  } from "./internal/buildCacheProviders";
69
- export { CacheError, CacheKeyError, CacheValueError, CacheCapacityError } from "./errors/CacheError";
69
+ export { CacheError, CacheKeyError, CacheValueError, CacheCapacityError, CacheDeclarationError, CacheUnavailableError } from "./errors/CacheError";
70
70
 
71
71
  export { OutputCache, type OutputCacheOptions as OutputCacheDecoratorOptions } from "./decorators/OutputCache";
72
72
  export {
@@ -91,7 +91,3 @@ export {
91
91
  } from "./internal/resolveCachePolicy";
92
92
  export { buildOutputCacheKey, OUTPUT_CACHE_PRINCIPAL_STATE_KEY } from "./http/buildOutputCacheKey";
93
93
  export { wrapCachedService, type CacheProxyOptions } from "./services/cacheProxy";
94
- export {
95
- cachedSingleton,
96
- cachedScoped,
97
- } from "./providers/cachedProviders";
@@ -11,7 +11,7 @@ import {
11
11
  } from "../decorators/cacheableRedisMetadata";
12
12
  import type { IDistributedCache } from "../distributed/IDistributedCache";
13
13
  import type { NamedCacheRegistry } from "../distributed/NamedCacheRegistry";
14
- import { CacheError } from "../errors/CacheError";
14
+ import { CacheError, CacheValueError } from "../errors/CacheError";
15
15
  import type { ICache } from "../ICache";
16
16
  import { normalizeCacheKey } from "../internal/normalizeCacheKey";
17
17
  import { AutomaticCacheKey } from "../internal/AutomaticCacheKey";
@@ -141,7 +141,11 @@ function wrapDistributedMethod(
141
141
  return result instanceof Promise ? await result : result;
142
142
  },
143
143
  { ttlSeconds: options.seconds, tags: options.tags, lockSeconds: options.lockSeconds },
144
- );
144
+ ).catch((error: unknown) => {
145
+ // Name the method: "value.at is a Date" alone does not say where.
146
+ if (error instanceof CacheValueError) throw new CacheValueError(`${className}.${String(methodName)}: ${error.message}`, { cause: error });
147
+ throw error;
148
+ });
145
149
  };
146
150
  }
147
151
 
@@ -22,7 +22,18 @@ export interface HostedServiceDiagnostics {
22
22
  warn(message: string, fields?: Readonly<Record<string, unknown>>): void;
23
23
  }
24
24
 
25
+ /** What a hosted service reports to `/health`. */
26
+ export interface HostedServiceHealth {
27
+ readonly healthy: boolean;
28
+ readonly details?: string;
29
+ }
30
+
25
31
  export interface HostedService {
32
+ /**
33
+ * The service's own state for `/health`. A `background` service of a module
34
+ * is reported as `background:<Class>`; without this method it is healthy.
35
+ */
36
+ checkHealth?(): HostedServiceHealth;
26
37
  /** Shared validators see real service identities, including inside supervised wrappers. */
27
38
  readonly planValidator?: HostedServicePlanValidator;
28
39
  /** Receives the application logger before start, when one is registered. */
@@ -39,6 +50,15 @@ export interface HostedService {
39
50
 
40
51
  export const HOSTED_SERVICE = createToken<HostedService>("IHostedService");
41
52
 
53
+ /** A health check contributed by a hosted service; the kernel reports it next to its own checks. */
54
+ export interface HostedServiceHealthCheck {
55
+ readonly name: string;
56
+ check(): HostedServiceHealth;
57
+ }
58
+
59
+ /** Enumerable: one entry per `background` service of the module graph. */
60
+ export const HOSTED_SERVICE_HEALTH = createToken<HostedServiceHealthCheck>("BazisHostedServiceHealth");
61
+
42
62
  interface HostedServiceResolver {
43
63
  resolveAll(token: typeof HOSTED_SERVICE): readonly HostedService[];
44
64
  }
@@ -1,6 +1,6 @@
1
1
  export { createInstance, type ActivatorDependency } from "./activator";
2
2
  export { Application, runApplication, type RunApplicationOptions } from "./application";
3
- export { addHostedService, HOSTED_SERVICE, startHostedServices, stopHostedServices, type HostedService, type HostedServiceDiagnostics, type HostedServicePlanValidator } from "./hosted-service";
3
+ export { addHostedService, HOSTED_SERVICE, HOSTED_SERVICE_HEALTH, startHostedServices, stopHostedServices, type HostedService, type HostedServiceDiagnostics, type HostedServiceHealth, type HostedServiceHealthCheck, type HostedServicePlanValidator } from "./hosted-service";
4
4
  export { SERVICE_PROVIDER } from "./service-provider-token";
5
5
  export {
6
6
  addOptions,
@@ -1,6 +1,6 @@
1
1
  import { DiContainer } from "../container";
2
2
  import { ServiceCollection } from "../ServiceCollection";
3
- import { HOSTED_SERVICE, type HostedService } from "../extensions/hosted-service";
3
+ import { HOSTED_SERVICE, HOSTED_SERVICE_HEALTH, type HostedService } from "../extensions/hosted-service";
4
4
  import { SERVICE_PROVIDER, SERVICE_PROVIDER_BY_TYPE } from "../extensions/service-provider-token";
5
5
  import { isClassProvider, ProviderDefinition, type Provider } from "../provider";
6
6
  import { getClassDeps } from "../internal/classDeps";
@@ -162,6 +162,13 @@ export function createContainer(rootModule: BazisModuleRef, options?: CreateCont
162
162
  collection.add(
163
163
  DI.singleton(DI.factoryProviderWithResolver(HOSTED_SERVICE, [], (resolver) => resolver.resolve(ServiceClass))),
164
164
  );
165
+ // `/health` sees a service that stopped for good, not only the process.
166
+ collection.add(
167
+ DI.singleton(DI.factoryProviderWithResolver(HOSTED_SERVICE_HEALTH, [], (resolver) => {
168
+ const service = resolver.resolve(ServiceClass);
169
+ return { name: `background:${ServiceClass.name}`, check: () => service.checkHealth?.() ?? { healthy: true } };
170
+ })),
171
+ );
165
172
  }
166
173
  }
167
174
 
@@ -25,6 +25,8 @@ export interface RedisDistributedCacheTuning {
25
25
  readonly maxValueBytes?: number;
26
26
  /** Polling interval (ms) while waiting for another worker's result. */
27
27
  readonly pollIntervalMs?: number;
28
+ /** Bound of one Redis operation (ms); on expiry the method runs without the cache. Default 1000. */
29
+ readonly operationTimeoutMs?: number;
28
30
  }
29
31
 
30
32
  const DEFAULT_KEY_PREFIX = "bazis:cache:";
@@ -59,6 +61,7 @@ export class RedisDistributedCacheBackend implements DistributedCacheStores {
59
61
  maxKeyLength: tuning.maxKeyLength,
60
62
  maxValueBytes: tuning.maxValueBytes,
61
63
  pollIntervalMs: tuning.pollIntervalMs,
64
+ operationTimeoutMs: tuning.operationTimeoutMs,
62
65
  };
63
66
 
64
67
  const output = new DistributedCache<CachedHttpPayload>(driver, httpPayloadCacheCodec, {
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
232
232
  if (method === "account/updated") this.modelCache = undefined;
233
233
  }, () => { this.clearLogin(); this.modelCache = undefined; });
234
234
  const initialized = await client.request("initialize", {
235
- clientInfo: { name: "bazis", title: "Bazis", version: "0.98.32" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.98.34" }, capabilities: { experimentalApi: true },
236
236
  }, 15_000, signal);
237
237
  if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
238
238
  client.notify("initialized");
@@ -3,6 +3,7 @@ import {
3
3
  DISTRIBUTED_CACHE_BACKEND,
4
4
  DISTRIBUTED_OUTPUT_CACHE,
5
5
  DISTRIBUTED_SERVICE_CACHE,
6
+ IDistributedCache,
6
7
  type DistributedCacheStores,
7
8
  } from "../../cache";
8
9
  import { createToken, DI, type InjectionToken, type ProviderDefinition } from "../../di";
@@ -60,6 +61,13 @@ function distributedCacheProviders(tuning: RedisDistributedCacheTuning, token: I
60
61
  backend.serviceCache,
61
62
  ),
62
63
  ),
64
+ // This connector's one connection by type, for services that evict tags or
65
+ // read values; its name may be "default" or the configured `connection`.
66
+ DI.singleton(
67
+ DI.factoryProvider(IDistributedCache, [DISTRIBUTED_CACHE_BACKEND], (backend: DistributedCacheStores) =>
68
+ backend.serviceCache.resolve(backend.serviceCache.connectionNames[0]),
69
+ ),
70
+ ),
63
71
  ];
64
72
  }
65
73
 
@@ -91,7 +99,7 @@ export function redisConnect<T extends RedisConfigShape>(
91
99
  token,
92
100
  config,
93
101
  providers: cacheProviders,
94
- exports: cacheProviders ? [DISTRIBUTED_CACHE_BACKEND, DISTRIBUTED_OUTPUT_CACHE, DISTRIBUTED_SERVICE_CACHE] : undefined,
102
+ exports: cacheProviders ? [DISTRIBUTED_CACHE_BACKEND, DISTRIBUTED_OUTPUT_CACHE, DISTRIBUTED_SERVICE_CACHE, IDistributedCache] : undefined,
95
103
  create(configs) {
96
104
  return new RedisClient(redisUrl(reader(config, configs).get("url")));
97
105
  },
@@ -1,4 +1,4 @@
1
- import type { ServiceResolver } from "../../di";
1
+ import { HOSTED_SERVICE_HEALTH, type ServiceResolver } from "../../di";
2
2
  import { redactSensitiveText } from "../../../library/redaction";
3
3
  import { awaitAbortable } from "../internal/awaitAbortable";
4
4
  import { KernelError } from "../errors";
@@ -21,7 +21,8 @@ export class HealthService {
21
21
  const timeoutMs = bounded(options.timeoutMs ?? 5000, "timeoutMs", 2147483647);
22
22
  const checkTimeoutMs = bounded(options.checkTimeoutMs ?? 1000, "checkTimeoutMs", 2147483647);
23
23
  const concurrency = bounded(options.concurrency ?? 4, "concurrency", 1024);
24
- const checks = this.resolver.resolveAll(HEALTH_CHECK);
24
+ // Hosted services (background) report whether they still run.
25
+ const checks: readonly HealthCheck[] = [...this.resolver.resolveAll(HEALTH_CHECK), ...this.resolver.resolveAll(HOSTED_SERVICE_HEALTH)];
25
26
  const entries: HealthReportEntry[] = new Array(checks.length);
26
27
  const report = new AbortController();
27
28
  const timer = setTimeout(() => report.abort(new Error("Health report timed out.")), timeoutMs);
package/index.ts CHANGED
@@ -204,8 +204,6 @@ export {
204
204
  export {
205
205
  Cacheable,
206
206
  OutputCache,
207
- cachedScoped,
208
- cachedSingleton,
209
207
  memory,
210
208
  } from "./core/cache";
211
209
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.98.32",
3
+ "version": "0.98.34",
4
4
  "description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Alexander Alenin",
@@ -1,42 +0,0 @@
1
- import {
2
- DI,
3
- HOSTED_SERVICE,
4
- Module,
5
- singleton,
6
- type Class,
7
- type HostedService,
8
- type BazisModule,
9
- type ProviderDefinition,
10
- } from "../di";
11
-
12
- export interface BackgroundModuleConfig {
13
- /**
14
- * Background service classes (subclasses of `BackgroundService` /
15
- * `PeriodicBackgroundService`). Each is registered as a singleton (with
16
- * auto-resolved constructor dependencies) and exposed as a `HOSTED_SERVICE`,
17
- * so the kernel starts and gracefully stops it with the rest of the app.
18
- */
19
- readonly services: readonly Class<HostedService>[];
20
- }
21
-
22
- /**
23
- * Registers background services in DI and wires them into the kernel lifecycle.
24
- *
25
- * ```ts
26
- * @Module({ imports: [backgroundModule({ services: [SessionCleanup, Heartbeat] })] })
27
- * class AppModule {}
28
- * ```
29
- */
30
- export function backgroundModule(config: BackgroundModuleConfig): BazisModule {
31
- const providers: ProviderDefinition[] = config.services.flatMap((ServiceClass) => [
32
- singleton(ServiceClass),
33
- DI.singleton(
34
- DI.factoryProviderWithResolver(HOSTED_SERVICE, [], (resolver) => resolver.resolve(ServiceClass)),
35
- ),
36
- ]);
37
-
38
- @Module({ providers })
39
- class BackgroundModule {}
40
-
41
- return BackgroundModule;
42
- }