bazis 0.98.9 → 0.98.10

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.
@@ -8,6 +8,7 @@ import {
8
8
  type BazisModuleRef,
9
9
  } from "../di";
10
10
  import { loadBazisGeneratedRuntime } from "../generatedRuntime";
11
+ import { missingCacheModuleCheck } from "../cache/http/missingCacheModuleCheck";
11
12
  import { httpModule, useModelValidator, type HttpModuleOptions, type ModelValidator } from "../http";
12
13
  import { grpcModule, type GrpcModuleOptions } from "../grpc";
13
14
  import { buildHttpOpenApiDocument } from "../http/OpenApi/openApiDocument";
@@ -256,6 +257,10 @@ export function composeApp(root: BazisModuleRef, options: RunAppOptions = {}): B
256
257
  if (options.cache !== undefined) {
257
258
  globals.push(options.cache);
258
259
  }
260
+ if (options.http !== undefined) {
261
+ // Warns about @OutputCache routes when no cache module is installed.
262
+ globals.push({ global: true, providers: [missingCacheModuleCheck()] });
263
+ }
259
264
  const appRoot: BazisModuleRef =
260
265
  globals.length > 0 || options.config !== undefined
261
266
  ? {
@@ -299,11 +299,11 @@ Infra separately publishes `DISTRIBUTED_CACHE_BACKEND`, `DISTRIBUTED_OUTPUT_CACH
299
299
 
300
300
  ## 5. Named policies
301
301
 
302
- Policies are reusable presets in `buildCacheModule({ policies })`. A decorator refers to one with `policy: "name"`.
302
+ Policies are reusable presets in `memory({ policies })` (since 0.98.10; before only the internal `buildCacheModule`). A decorator refers to one with `policy: "name"`.
303
303
  Inline decorator fields **override** the policy (policy → inline, inline wins).
304
304
 
305
305
  ```ts
306
- buildCacheModule({
306
+ memory({
307
307
  policies: {
308
308
  catalog: {
309
309
  seconds: 60,
@@ -340,7 +340,7 @@ Caches the **full HTTP response** after the action runs. On a cache hit the cont
340
340
  | Field | Type | Default | Description |
341
341
  | --- | --- | --- | --- |
342
342
  | `seconds` | `number` | — | **Required** (inline or in the policy). The entry TTL |
343
- | `policy` | `string` | — | A policy name from `buildCacheModule({ policies })` |
343
+ | `policy` | `string` | — | A policy name from `memory({ policies })` |
344
344
  | `tags` | `string[]` | — | Tags for `ICache.evictByTag(tag)` |
345
345
  | `enabled` | `boolean` | `true` | `false`: the middleware is not attached (the metadata is kept) |
346
346
  | `noStore` | `boolean` | `false` | `true`: neither read nor write the cache |
@@ -440,6 +440,8 @@ Sensitive response headers are **neither stored nor replayed**:
440
440
 
441
441
  The body is read whole (`arrayBuffer`): **streaming responses are not supported**.
442
442
 
443
+ A response served from the cache carries `Age`: seconds since it was stored (since 0.98.10).
444
+
443
445
  ---
444
446
 
445
447
  ## 7. `@OutputRedisCache` — HTTP output cache (distributed)
@@ -1007,7 +1009,7 @@ without varyByUser or unlessAuthenticated — …
1007
1009
  ```
1008
1010
 
1009
1011
  For a migration you can temporarily enable the warning mode:
1010
- `buildCacheModule({ outputCache: { insecureAuthorizedRouteBehavior: "warn" } })`.
1012
+ `memory({ outputCache: { insecureAuthorizedRouteBehavior: "warn" } })`.
1011
1013
  Disabling it fully (`"ignore"`) is acceptable only with an external security policy.
1012
1014
 
1013
1015
  ### 17.2. Recommendations by scenario
@@ -1,21 +1,32 @@
1
1
  import type { BazisModuleRef } from "../di";
2
- import { buildCacheModule } from "./internal/buildCacheProviders";
2
+ import { buildCacheModule, type CacheOutputCacheOptions } from "./internal/buildCacheProviders";
3
3
  import type { CacheOptions } from "./types/CacheOptions";
4
+ import type { CachePolicyRegistry } from "./types/CachePolicy";
5
+
6
+ /** Options of {@link memory}: the in-memory store plus named policies and output-cache settings. */
7
+ export interface MemoryCacheOptions extends CacheOptions {
8
+ /** Named policies shared by `@OutputCache({ policy })` and `@Cacheable({ policy })`. */
9
+ readonly policies?: CachePolicyRegistry;
10
+ /** HTTP output cache settings: global switch and the check of authorized routes. */
11
+ readonly outputCache?: CacheOutputCacheOptions;
12
+ }
4
13
 
5
14
  /**
6
15
  * In-memory cache as a one-line value. Inject {@link ICache} anywhere; decorate
7
16
  * methods with `@Cacheable` or routes with `@OutputCache`.
8
17
  *
9
18
  * ```ts
10
- * createApp({ cache: memory({ maxEntries: 1000 }) });
19
+ * await runApp(AppModule, {
20
+ * cache: memory({ maxEntries: 1000, policies: { catalog: { seconds: 60, tags: ["catalog"] } } }),
21
+ * http: { port: 3000 },
22
+ * });
11
23
  * // or, inside a module: imports: [memory()]
12
24
  * ```
13
25
  *
14
- * Options are just the in-memory knobs (`maxEntries`, `defaultTtlSeconds`, …).
15
26
  * For multi-instance caching keep `memory()` and add a distributed backend in your
16
27
  * `@Infra` manifest via `redisConnect(redisConfig, { cache: "distributed" })`; `@CacheableRedis` /
17
28
  * `@OutputRedisCache` then pick the backend up from DI automatically.
18
29
  */
19
- export function memory(options: CacheOptions = {}): BazisModuleRef {
30
+ export function memory(options: MemoryCacheOptions = {}): BazisModuleRef {
20
31
  return buildCacheModule(options);
21
32
  }
@@ -11,6 +11,7 @@ interface SerializedHttpPayload {
11
11
  readonly status: number;
12
12
  readonly headers: Readonly<Record<string, string>>;
13
13
  readonly body: string;
14
+ readonly storedAt?: number;
14
15
  }
15
16
 
16
17
  /** Codec for HTTP output payloads — body is base64 because JSON cannot hold bytes. */
@@ -20,6 +21,7 @@ export const httpPayloadCacheCodec: CacheCodec<CachedHttpPayload> = {
20
21
  status: payload.status,
21
22
  headers: payload.headers,
22
23
  body: Buffer.from(payload.body).toString("base64"),
24
+ ...(payload.storedAt !== undefined ? { storedAt: payload.storedAt } : {}),
23
25
  } satisfies SerializedHttpPayload),
24
26
  deserialize: (raw) => {
25
27
  const parsed = JSON.parse(raw) as SerializedHttpPayload;
@@ -27,6 +29,7 @@ export const httpPayloadCacheCodec: CacheCodec<CachedHttpPayload> = {
27
29
  status: parsed.status,
28
30
  headers: parsed.headers,
29
31
  body: Uint8Array.from(Buffer.from(parsed.body, "base64")),
32
+ ...(typeof parsed.storedAt === "number" ? { storedAt: parsed.storedAt } : {}),
30
33
  };
31
34
  },
32
35
  };
@@ -25,6 +25,8 @@ export interface CachedHttpPayload {
25
25
  readonly status: number;
26
26
  readonly headers: Readonly<Record<string, string>>;
27
27
  readonly body: Uint8Array;
28
+ /** When the response was stored (epoch ms); a cache hit derives `Age` from it. */
29
+ readonly storedAt?: number;
28
30
  }
29
31
 
30
32
  export interface CachedHttpPayloadReadOptions {
@@ -105,8 +107,9 @@ export async function responseToCachedPayload(
105
107
  headers[lower] = value;
106
108
  }
107
109
  });
110
+ const storedAt = Date.now();
108
111
  if (response.body === null) {
109
- return { status: response.status, headers, body: new Uint8Array() };
112
+ return { status: response.status, headers, body: new Uint8Array(), storedAt };
110
113
  }
111
114
 
112
115
  let clone: ReturnType<Response["clone"]>;
@@ -124,13 +127,21 @@ export async function responseToCachedPayload(
124
127
  // The cached payload replaces the original response. Release its tee branch
125
128
  // after the clone has been fully materialized.
126
129
  await response.body.cancel().catch(() => undefined);
127
- return { status: response.status, headers, body };
130
+ return { status: response.status, headers, body, storedAt };
128
131
  }
129
132
 
130
- export function cachedPayloadToResponse(payload: CachedHttpPayload): Response {
133
+ /**
134
+ * Rebuilds the stored response. `hit: true` marks a response served from the
135
+ * cache with `Age` — seconds since it was stored (RFC 9111).
136
+ */
137
+ export function cachedPayloadToResponse(payload: CachedHttpPayload, options: { readonly hit?: boolean } = {}): Response {
138
+ const headers = stripSensitiveResponseHeaders(payload.headers);
139
+ if (options.hit && payload.storedAt !== undefined) {
140
+ headers.age = String(Math.max(0, Math.floor((Date.now() - payload.storedAt) / 1000)));
141
+ }
131
142
  return new Response(payload.body as unknown as ConstructorParameters<typeof Response>[0], {
132
143
  status: payload.status,
133
- headers: stripSensitiveResponseHeaders(payload.headers),
144
+ headers,
134
145
  });
135
146
  }
136
147
 
@@ -0,0 +1,32 @@
1
+ import { singletonFactoryWithResolver, type ProviderDefinition } from "../../di";
2
+ import { ROUTE_MIDDLEWARE_COMPOSER, type RouteMiddlewareComposerRegistration } from "../../http";
3
+ import { LOGGER } from "../../kernel";
4
+ import { outputCacheMetaOf, resolveOutputCacheRequirement } from "../decorators/outputCacheMetadata";
5
+ import { ICache } from "../ICache";
6
+
7
+ /**
8
+ * A route composer the application composition installs next to the HTTP
9
+ * server: it adds no middleware and only warns at startup about each
10
+ * `@OutputCache` route when no cache module is installed — then the decorator
11
+ * does nothing and every request runs the action.
12
+ */
13
+ export function missingCacheModuleCheck(): ProviderDefinition {
14
+ return singletonFactoryWithResolver(ROUTE_MIDDLEWARE_COMPOSER, [], (resolver): RouteMiddlewareComposerRegistration => {
15
+ const cacheInstalled = resolver.tryResolve(ICache) !== undefined;
16
+ const logger = resolver.tryResolve(LOGGER);
17
+ return {
18
+ order: 0,
19
+ compose: (controllerClass, methodName) => {
20
+ if (cacheInstalled) return [];
21
+ const requirement = resolveOutputCacheRequirement(outputCacheMetaOf(controllerClass), methodName);
22
+ if (requirement === undefined || requirement.enabled === false) return [];
23
+ const message = `[cache] @OutputCache on ${controllerClass.name}.${String(methodName)} has no effect: `
24
+ + "no cache module is installed, so every request runs the action. "
25
+ + "Add `cache: memory()` to the runApp options.";
26
+ if (logger) logger.warn(message);
27
+ else console.warn(message);
28
+ return [];
29
+ },
30
+ };
31
+ });
32
+ }
@@ -79,7 +79,7 @@ export function outputCacheMiddleware(options: OutputCacheMiddlewareOptions): Ht
79
79
  const key = buildOutputCacheKey(ctx, routeName, config);
80
80
  const cached = cache.get(key);
81
81
  if (cached !== undefined) {
82
- ctx.response = applyClientCacheHeaders(cachedPayloadToResponse(cached), clientCache);
82
+ ctx.response = applyClientCacheHeaders(cachedPayloadToResponse(cached, { hit: true }), clientCache);
83
83
  return;
84
84
  }
85
85
 
@@ -83,7 +83,7 @@ export function outputRedisCacheMiddleware(options: OutputRedisCacheMiddlewareOp
83
83
  const key = buildRedisOutputCacheKey(ctx, routeName, config);
84
84
  const cached = await cache.get(key);
85
85
  if (cached !== undefined) {
86
- ctx.response = applyClientCacheHeaders(cachedPayloadToResponse(cached), clientCache);
86
+ ctx.response = applyClientCacheHeaders(cachedPayloadToResponse(cached, { hit: true }), clientCache);
87
87
  return;
88
88
  }
89
89
 
@@ -59,7 +59,7 @@ export {
59
59
  DISTRIBUTED_SERVICE_CACHE,
60
60
  } from "./tokens/DISTRIBUTED_CACHE";
61
61
 
62
- export { memory } from "./cacheModule";
62
+ export { memory, type MemoryCacheOptions } from "./cacheModule";
63
63
  /** @internal Advanced builder used by the hosting layer. */
64
64
  export {
65
65
  buildCacheModule,
@@ -36,6 +36,7 @@ import {
36
36
  isFactoryProvider,
37
37
  isKeyedDependency,
38
38
  isLazyDependency,
39
+ isOptionalDependency,
39
40
  isNamedDependency,
40
41
  isValueProvider,
41
42
  } from "./provider";
@@ -78,6 +79,7 @@ export class ServiceProvider implements ServiceResolver {
78
79
  ? { required: provider.useClass.length, declared: this.getClassProviderDeps(provider).length }
79
80
  : undefined,
80
81
  describeDependency: (dependency) => this.getDependencyDescriptor(dependency),
82
+ hasName: (name) => this.registry.lookupName(name) !== undefined,
81
83
  findRegistration: (token, key) => this.registry.find(token, key),
82
84
  };
83
85
  }
@@ -417,6 +419,9 @@ export class ServiceProvider implements ServiceResolver {
417
419
  resolved[index] = this.createLazy(dependency.token, dependency.key, scopeState, ownerLifetime, stack);
418
420
  continue;
419
421
  }
422
+ if (dependency.optional && !this.registry.find(dependency.token, dependency.key)) {
423
+ continue;
424
+ }
420
425
  resolved[index] = await this.resolveSingleAsync(dependency.token, dependency.key, scopeState, stack, ownerLifetime);
421
426
  }
422
427
  return resolved;
@@ -560,6 +565,9 @@ export class ServiceProvider implements ServiceResolver {
560
565
  if (dependency === undefined) {
561
566
  continue;
562
567
  }
568
+ if (dependency.optional && !this.registry.find(dependency.token, dependency.key)) {
569
+ continue;
570
+ }
563
571
  resolved[index] = dependency.lazy
564
572
  ? this.createLazy(dependency.token, dependency.key, scopeState, ownerLifetime, stack)
565
573
  : this.resolveSingle(dependency.token, dependency.key, scopeState, stack, ownerLifetime);
@@ -584,8 +592,14 @@ export class ServiceProvider implements ServiceResolver {
584
592
  continue;
585
593
  }
586
594
  const lazy = isLazyDependency(dep);
587
- const descriptor = this.getDependencyDescriptor(lazy ? dep.inner : dep);
588
- plan[index] = { token: descriptor.token, key: descriptor.key, lazy };
595
+ const optional = isOptionalDependency(dep);
596
+ if (optional && isNamedDependency(dep.inner) && !this.registry.lookupName(dep.inner.name)) {
597
+ // An optional dependency on a name nothing registered stays unset.
598
+ plan[index] = undefined;
599
+ continue;
600
+ }
601
+ const descriptor = this.getDependencyDescriptor(lazy || optional ? dep.inner : dep);
602
+ plan[index] = { token: descriptor.token, key: descriptor.key, lazy, ...(optional ? { optional } : {}) };
589
603
  }
590
604
  this.planCache.set(provider, plan);
591
605
  return plan;
package/core/di/index.ts CHANGED
@@ -82,7 +82,7 @@ export {
82
82
  type OpenGenericTokenFamily,
83
83
  type Token,
84
84
  } from "./token";
85
- export { ProviderDefinition, keyedDependency, lazyDependency, namedDependency } from "./provider";
85
+ export { ProviderDefinition, keyedDependency, lazyDependency, namedDependency, optionalDependency } from "./provider";
86
86
  export type {
87
87
  AsyncFactoryProvider,
88
88
  ClassProvider,
@@ -90,6 +90,7 @@ export type {
90
90
  KeyedDependency,
91
91
  Lazy,
92
92
  LazyDependency,
93
+ OptionalDependency,
93
94
  NamedDependency,
94
95
  Provider,
95
96
  ProviderDependencyList,
@@ -1,5 +1,5 @@
1
1
  import { ServiceValidationError } from "../errors";
2
- import { isLazyDependency } from "../provider";
2
+ import { isLazyDependency, isNamedDependency, isOptionalDependency } from "../provider";
3
3
  import type {
4
4
  KeyedDependency,
5
5
  NamedDependency,
@@ -24,6 +24,8 @@ export interface GraphValidationContext {
24
24
  dependencies(provider: Provider<unknown>): ProviderDependencyList;
25
25
  /** Constructor arity vs declared deps for class providers; `undefined` for non-class providers. */
26
26
  classShape(provider: Provider<unknown>): { readonly required: number; readonly declared: number } | undefined;
27
+ /** True when some registration carries this name (for optional named dependencies). */
28
+ hasName(name: string): boolean;
27
29
  describeDependency(
28
30
  dependency: Token<unknown> | KeyedDependency<unknown> | NamedDependency<unknown>,
29
31
  ): { readonly token: Token<unknown>; readonly key: ServiceKey | undefined };
@@ -88,8 +90,17 @@ export class GraphValidator {
88
90
  continue;
89
91
  }
90
92
  const isLazy = isLazyDependency(dependency);
91
- const descriptor = this.context.describeDependency(isLazy ? dependency.inner : dependency);
93
+ const isOptional = isOptionalDependency(dependency);
94
+ const inner = isLazy || isOptional ? dependency.inner : dependency;
95
+ // An optional dependency on a name nothing registered is simply absent.
96
+ if (isOptional && isNamedDependency(inner) && !this.context.hasName(inner.name)) {
97
+ continue;
98
+ }
99
+ const descriptor = this.context.describeDependency(inner);
92
100
  const depRegistration = this.context.findRegistration(descriptor.token, descriptor.key);
101
+ if (!depRegistration && isOptional) {
102
+ continue;
103
+ }
93
104
  if (!depRegistration) {
94
105
  issues.add(
95
106
  `Missing dependency "${tokenToDebugName(descriptor.token)}" for "${tokenToDebugName(registration.token)}"`,
@@ -13,6 +13,8 @@ export interface PlannedDependency {
13
13
  readonly token: Token<unknown>;
14
14
  readonly key: ServiceKey | undefined;
15
15
  readonly lazy: boolean;
16
+ /** Resolve to `undefined` when the token has no registration. */
17
+ readonly optional?: boolean;
16
18
  }
17
19
 
18
20
  export type ResolutionPlan = readonly (PlannedDependency | undefined)[];
@@ -1,7 +1,8 @@
1
- import { lazyDependency, namedDependency, type ProviderDependencyList } from "../provider";
1
+ import { lazyDependency, namedDependency, optionalDependency, type ProviderDependencyList } from "../provider";
2
2
  import type { Class } from "../token";
3
3
 
4
4
  const LAZY_PREFIX = "lazy:";
5
+ const OPTIONAL_PREFIX = "optional:";
5
6
  // These registrations live for the process lifetime just like their
6
7
  // constructors. A Map, rather than a WeakMap, lets the generated-runtime
7
8
  // owner take an exact rollback snapshot before publishing a target slice.
@@ -24,7 +25,9 @@ export function getGeneratedClassDeps(useClass: Class<unknown>): ProviderDepende
24
25
  typeof dependency !== "string" ? dependency
25
26
  : dependency.startsWith(LAZY_PREFIX)
26
27
  ? lazyDependency(namedDependency(dependency.slice(LAZY_PREFIX.length)))
27
- : namedDependency(dependency),
28
+ : dependency.startsWith(OPTIONAL_PREFIX)
29
+ ? optionalDependency(namedDependency(dependency.slice(OPTIONAL_PREFIX.length)))
30
+ : namedDependency(dependency),
28
31
  );
29
32
  }
30
33
 
@@ -1,7 +1,7 @@
1
1
  import { ModuleEncapsulationError } from "../errors";
2
2
  import { getProviderDeps } from "../internal/providerDeps";
3
3
  import type { ProviderDefinition, ProviderDependencyList } from "../provider";
4
- import { isKeyedDependency, isLazyDependency, isNamedDependency } from "../provider";
4
+ import { isKeyedDependency, isLazyDependency, isNamedDependency, isOptionalDependency } from "../provider";
5
5
  import type { OpenGenericTokenFamily, Token } from "../token";
6
6
  import { tokenToDebugName } from "../token";
7
7
  import { applyNamedDependencyEncapsulationHooks } from "./encapsulationHooks";
@@ -249,7 +249,7 @@ function checkDependency(
249
249
 
250
250
  // Laziness changes "when" a dependency is created, not "whether" the module
251
251
  // may see it — validate the inner descriptor with the same rules.
252
- if (isLazyDependency(dep)) {
252
+ if (isLazyDependency(dep) || isOptionalDependency(dep)) {
253
253
  checkDependency(record, definition, dep.inner, context);
254
254
  return;
255
255
  }
@@ -3,7 +3,9 @@ import { getProviderDeps, withProviderDeps } from "../internal/providerDeps";
3
3
  import {
4
4
  isLazyDependency,
5
5
  isNamedDependency,
6
+ isOptionalDependency,
6
7
  lazyDependency,
8
+ optionalDependency,
7
9
  ProviderDefinition,
8
10
  } from "../provider";
9
11
  import type { ProviderDependencyList } from "../provider";
@@ -118,6 +120,14 @@ function rebindDependency(dep: Dependency, nameMap: Map<string, Token<unknown>[]
118
120
  }
119
121
  return dep;
120
122
  }
123
+ if (isOptionalDependency(dep)) {
124
+ const inner = dep.inner;
125
+ if (isNamedDependency(inner)) {
126
+ const token = resolveName(inner.name, nameMap);
127
+ return token ? optionalDependency(token) : dep;
128
+ }
129
+ return dep;
130
+ }
121
131
  if (isNamedDependency(dep)) {
122
132
  return resolveName(dep.name, nameMap) ?? dep;
123
133
  }
@@ -2,7 +2,7 @@ import { ModuleEncapsulationError } from "../errors";
2
2
  import { getProviderDeps } from "../internal/providerDeps";
3
3
  import { NamedTokenIndex } from "../internal/NamedTokenIndex";
4
4
  import type { OpenGenericRegistration } from "../internal/OpenGenericRegistration";
5
- import { isKeyedDependency, isLazyDependency, isNamedDependency, type ProviderDefinition } from "../provider";
5
+ import { isKeyedDependency, isLazyDependency, isNamedDependency, isOptionalDependency, type ProviderDefinition } from "../provider";
6
6
  import { tokenToDebugName, type Token } from "../token";
7
7
  import type { ServiceKey } from "../types";
8
8
  import { isFamilyExport, type ModuleGraphRecord } from "./encapsulation";
@@ -78,7 +78,7 @@ export function createProviderSelectionValidator(
78
78
  return (definition, record) => {
79
79
  for (const dependency of getProviderDeps(definition.provider)) {
80
80
  if (dependency === undefined) continue;
81
- const dep = isLazyDependency(dependency) ? dependency.inner : dependency;
81
+ const dep = isLazyDependency(dependency) || isOptionalDependency(dependency) ? dependency.inner : dependency;
82
82
  const token = isNamedDependency(dep) ? names.lookup(dep.name) : isKeyedDependency(dep) ? dep.token : dep;
83
83
  if (!token) continue; // Missing dependencies retain their normal DI error.
84
84
  const key = isKeyedDependency(dep) ? dep.key : undefined;
@@ -18,9 +18,12 @@ export {
18
18
  isNamedDependency,
19
19
  lazyDependency,
20
20
  isLazyDependency,
21
+ optionalDependency,
22
+ isOptionalDependency,
21
23
  ProviderDefinition,
22
24
  type KeyedDependency,
23
25
  type Lazy,
24
26
  type LazyDependency,
27
+ type OptionalDependency,
25
28
  } from "./types";
26
29
  export { isAsyncFactoryProvider, isClassProvider, isFactoryProvider, isValueProvider } from "./providerGuards";
@@ -0,0 +1,26 @@
1
+ import type { Token } from "../../token";
2
+ import type { KeyedDependency } from "./KeyedDependency";
3
+ import type { NamedDependency } from "./NamedDependency";
4
+
5
+ /**
6
+ * Optional dependency: resolved when the token is registered, `undefined`
7
+ * otherwise. Codegen emits it for an optional constructor parameter
8
+ * (`cache?: ICache`, or one with a default value).
9
+ */
10
+ export interface OptionalDependency<T> {
11
+ readonly optional: true;
12
+ readonly inner: Token<T> | KeyedDependency<T> | NamedDependency<T>;
13
+ }
14
+
15
+ export function optionalDependency<T>(inner: Token<T> | KeyedDependency<T> | NamedDependency<T>): OptionalDependency<T> {
16
+ return { optional: true, inner };
17
+ }
18
+
19
+ export function isOptionalDependency(value: unknown): value is OptionalDependency<unknown> {
20
+ return Boolean(
21
+ value &&
22
+ typeof value === "object" &&
23
+ (value as Record<string, unknown>).optional === true &&
24
+ "inner" in (value as Record<string, unknown>),
25
+ );
26
+ }
@@ -2,10 +2,12 @@ import type { Token } from "../../token";
2
2
  import type { KeyedDependency } from "./KeyedDependency";
3
3
  import type { LazyDependency } from "./LazyDependency";
4
4
  import type { NamedDependency } from "./NamedDependency";
5
+ import type { OptionalDependency } from "./OptionalDependency";
5
6
 
6
7
  export type ProviderDependencyList = readonly (
7
8
  | Token<unknown>
8
9
  | KeyedDependency<unknown>
9
10
  | NamedDependency<unknown>
10
11
  | LazyDependency<unknown>
12
+ | OptionalDependency<unknown>
11
13
  )[];
@@ -2,12 +2,15 @@ import type { Token } from "../../token";
2
2
  import type { KeyedDependency } from "./KeyedDependency";
3
3
  import type { Lazy, LazyDependency } from "./LazyDependency";
4
4
  import type { NamedDependency } from "./NamedDependency";
5
+ import type { OptionalDependency } from "./OptionalDependency";
5
6
 
6
7
  export type ResolvedDeps<
7
- D extends readonly (Token<unknown> | KeyedDependency<unknown> | NamedDependency<unknown> | LazyDependency<unknown>)[],
8
+ D extends readonly (Token<unknown> | KeyedDependency<unknown> | NamedDependency<unknown> | LazyDependency<unknown> | OptionalDependency<unknown>)[],
8
9
  > = {
9
10
  [K in keyof D]: D[K] extends LazyDependency<infer TResolvedLazy>
10
11
  ? Lazy<TResolvedLazy>
12
+ : D[K] extends OptionalDependency<infer TResolvedOptional>
13
+ ? TResolvedOptional | undefined
11
14
  : D[K] extends Token<infer TResolved>
12
15
  ? TResolved
13
16
  : D[K] extends KeyedDependency<infer TResolvedKeyed>
@@ -4,6 +4,7 @@ export type { FactoryProvider } from "./FactoryProvider";
4
4
  export { keyedDependency, isKeyedDependency, type KeyedDependency } from "./KeyedDependency";
5
5
  export { namedDependency, isNamedDependency, type NamedDependency } from "./NamedDependency";
6
6
  export { lazyDependency, isLazyDependency, type Lazy, type LazyDependency } from "./LazyDependency";
7
+ export { optionalDependency, isOptionalDependency, type OptionalDependency } from "./OptionalDependency";
7
8
  export type { ProviderDependencyList } from "./ProviderDependencyList";
8
9
  export { ProviderDefinition } from "./ProviderDefinition";
9
10
  export type { ProviderLifetime } from "./ProviderLifetime";
@@ -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.9" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.98.10" }, 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");
@@ -152,7 +152,7 @@ const FRAMEWORK_INTERNAL_PREFIXES = FRAMEWORK_SOURCE === null ? [] : [
152
152
  ].map((prefix) => `${FRAMEWORK_SOURCE}/${prefix}`);
153
153
 
154
154
  // A declaration is an identity; short names are local to their source/module.
155
- interface CollectedDependency { readonly name: string; readonly target?: ts.ClassDeclaration; readonly lazy?: boolean; }
155
+ interface CollectedDependency { readonly name: string; readonly target?: ts.ClassDeclaration; readonly lazy?: boolean; readonly optional?: boolean; }
156
156
  interface ConstructorDependencies {
157
157
  readonly deps: CollectedDependency[];
158
158
  readonly unsupportedInheritedParameter?: string;
@@ -619,7 +619,9 @@ function renderTargetDeps(): string {
619
619
  }
620
620
  }
621
621
  const lines = ["// This file is auto-generated by `bazis codegen`.", "// Do not edit manually."];
622
- if (entries.some(([, deps]) => deps.some(dep => dep.target && dep.lazy))) lines.push('import { lazyDependency } from "bazis/core/di";');
622
+ const helpers = ["lazyDependency", "optionalDependency"].filter((helper) => entries.some(([, deps]) =>
623
+ deps.some((dep) => dep.target && (helper === "lazyDependency" ? dep.lazy : dep.optional))));
624
+ if (helpers.length > 0) lines.push(`import { ${helpers.join(", ")} } from "bazis/core/di";`);
623
625
  for (const [declaration, reference] of references) {
624
626
  const name = dependencyClassExportName(declaration);
625
627
  if (!name) throw new Error(`BAZIS_DI_CLASS_UNIMPORTABLE: ${sourceLocation(declaration)}`);
@@ -628,9 +630,9 @@ function renderTargetDeps(): string {
628
630
  lines.push("export const GENERATED_TARGET_CLASS_DEPS = Object.freeze([");
629
631
  for (const [declaration, dependencies] of entries) {
630
632
  const values = dependencies.map(dependency => {
631
- if (!dependency.target) return JSON.stringify(dependency.name);
633
+ if (!dependency.target) return JSON.stringify(dependency.optional ? `optional:${dependency.name}` : dependency.name);
632
634
  const reference = references.get(dependency.target)!;
633
- return dependency.lazy ? `lazyDependency(${reference})` : reference;
635
+ return dependency.lazy ? `lazyDependency(${reference})` : dependency.optional ? `optionalDependency(${reference})` : reference;
634
636
  });
635
637
  lines.push(` [${references.get(declaration)}, [${values.join(", ")}]] as const,`);
636
638
  }
@@ -762,7 +764,7 @@ function resolveConfigViewDeps(): void {
762
764
  changed = false;
763
765
  break;
764
766
  }
765
- resolved.push({ name: `Config:${prefixes[0]}` });
767
+ resolved.push({ name: `Config:${prefixes[0]}`, ...(dep.optional ? { optional: true } : {}) });
766
768
  changed = true;
767
769
  }
768
770
  if (changed && classDeps.has(declaration)) classDeps.set(declaration, resolved);
@@ -782,6 +784,12 @@ function validateDepsAgainstKnownTokens(): void {
782
784
  ) {
783
785
  continue;
784
786
  }
787
+ // An optional trailing parameter of a type DI does not know (`options: Options = {}`)
788
+ // is not injected: the constructor gets its default, as without codegen.
789
+ if (deps[index]!.optional) {
790
+ classDeps.set(declaration, deps.slice(0, index));
791
+ break;
792
+ }
785
793
  classDeps.delete(declaration);
786
794
  // A class DI must construct cannot lose a dependency silently: the app
787
795
  // would fail later, far from the cause. Other classes need no entry.
@@ -935,8 +943,11 @@ function readInheritedConstructorDeps(node: ts.ClassDeclaration): ConstructorDep
935
943
  }
936
944
  }
937
945
  const deps: CollectedDependency[] = [];
938
- for (let index = 0; index <= lastRequired; index++) {
946
+ for (let index = 0; index < parameters.length; index++) {
939
947
  const parameter = parameters[index]!;
948
+ const parameterDeclaration = parameter.valueDeclaration;
949
+ const optional = parameterDeclaration !== undefined && ts.isParameter(parameterDeclaration)
950
+ && (parameterDeclaration.questionToken !== undefined || parameterDeclaration.initializer !== undefined);
940
951
  let type = checker.getTypeOfSymbolAtLocation(parameter, node);
941
952
  const declaration = parameter.valueDeclaration;
942
953
  // A default before a required argument still occupies a DI position. The
@@ -945,9 +956,14 @@ function readInheritedConstructorDeps(node: ts.ClassDeclaration): ConstructorDep
945
956
  const defined = type.types.filter((member) => !(member.flags & ts.TypeFlags.Undefined));
946
957
  if (defined.length === 1) type = defined[0]!;
947
958
  }
959
+ if (optional && type.isUnion()) {
960
+ const defined = type.types.filter((member) => !(member.flags & ts.TypeFlags.Undefined));
961
+ if (defined.length === 1) type = defined[0]!;
962
+ }
948
963
  const dependency = dependencyFromType(type);
964
+ if (!dependency && index > lastRequired) break;
949
965
  if (!dependency) return { deps: [], unsupportedInheritedParameter: parameter.name };
950
- deps.push(dependency);
966
+ deps.push(optional && !dependency.lazy ? { ...dependency, optional: true } : dependency);
951
967
  }
952
968
  return { deps };
953
969
  }
@@ -983,16 +999,22 @@ function readConstructorTypeNames(node: ts.ClassDeclaration): ConstructorDepende
983
999
 
984
1000
  const deps: CollectedDependency[] = [];
985
1001
  const lastRequiredParamIndex = findLastRequiredParameterIndex(ctor);
986
- for (let index = 0; index <= lastRequiredParamIndex; index += 1) {
1002
+ for (let index = 0; index < ctor.parameters.length; index += 1) {
987
1003
  const param = ctor.parameters[index];
988
1004
  if (!param) {
989
1005
  continue;
990
1006
  }
1007
+ // Optional parameters (`cache?: ICache`, `clock: Clock = new SystemClock()`)
1008
+ // are injected when registered and left unset otherwise.
1009
+ const optional = param.questionToken !== undefined || param.initializer !== undefined;
1010
+ const trailing = index > lastRequiredParamIndex;
991
1011
  const paramType = param.type;
992
1012
  const refName = paramType ? getDependencyTypeName(paramType) : undefined;
993
1013
  if (!refName || BUILTIN_DEPENDENCY_NAMES.has(refName)) {
1014
+ if (trailing) break;
994
1015
  return { deps: [], unsupportedParameter: { index, name: param.name.getText(), type: paramType?.getText() ?? "(no type)" } };
995
1016
  }
1017
+ const optionalFlag = optional ? { optional: true } : {};
996
1018
 
997
1019
  // `Lazy<X>` is a deferred dependency on X: encode it with a marker prefix
998
1020
  // so the runtime injects a Lazy wrapper instead of resolving X eagerly.
@@ -1010,7 +1032,7 @@ function readConstructorTypeNames(node: ts.ClassDeclaration): ConstructorDepende
1010
1032
  const configType = paramType.typeArguments?.[0];
1011
1033
  const configTypeName = configType ? getDependencyTypeName(configType) : undefined;
1012
1034
  if (configTypeName) {
1013
- deps.push({ name: `ConfigView<${configTypeName}>` });
1035
+ deps.push({ name: `ConfigView<${configTypeName}>`, ...optionalFlag });
1014
1036
  continue;
1015
1037
  }
1016
1038
  }
@@ -1019,12 +1041,12 @@ function readConstructorTypeNames(node: ts.ClassDeclaration): ConstructorDepende
1019
1041
  const entityType = paramType.typeArguments?.[0];
1020
1042
  const entityName = entityType ? getDependencyTypeName(entityType) : undefined;
1021
1043
  if (entityName) {
1022
- deps.push({ name: `IRepository<${entityName}>` });
1044
+ deps.push({ name: `IRepository<${entityName}>`, ...optionalFlag });
1023
1045
  continue;
1024
1046
  }
1025
1047
  }
1026
1048
 
1027
- deps.push({ name: refName, target: dependencyClass(paramType!) });
1049
+ deps.push({ name: refName, target: dependencyClass(paramType!), ...optionalFlag });
1028
1050
  }
1029
1051
 
1030
1052
  return { deps };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.98.9",
3
+ "version": "0.98.10",
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",