bazis 0.108.0 → 0.109.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import { createToken, type InjectionToken } from "../di";
2
+ import { registerNamedDependencyHint } from "../di/errors/NamedDependencyNotFoundError";
2
3
  import type { CacheEntry } from "./types/CacheEntry";
3
4
  import type { CacheSetOptions } from "./types/CacheOptions";
4
5
 
@@ -65,6 +66,10 @@ export interface ICache<TValue = unknown> {
65
66
  /** Default cache DI token. */
66
67
  export const ICache = createToken<ICache>("ICache") as InjectionToken<ICache<unknown>>;
67
68
 
69
+ registerNamedDependencyHint((name) => name === "ICache" || name.startsWith("ICache<")
70
+ ? "The cache is provided by a cache module: add cache: memory() to the runApp options (startTestApp takes the same options)."
71
+ : undefined);
72
+
68
73
  /** Typed token for a specific value type. */
69
74
  export function cacheToken<TValue>(name: string): InjectionToken<ICache<TValue>> {
70
75
  return createToken<ICache<TValue>>(`ICache<${name}>`);
@@ -1,4 +1,5 @@
1
1
  import { createToken, type InjectionToken } from "../../di";
2
+ import { registerNamedDependencyHint } from "../../di/errors/NamedDependencyNotFoundError";
2
3
  import type { CacheSetOptions } from "../types/CacheOptions";
3
4
 
4
5
  /** Default named connection used when a decorator omits `connection`. */
@@ -47,3 +48,7 @@ export interface IDistributedCache<TValue = unknown> {
47
48
  * `DISTRIBUTED_SERVICE_CACHE.resolve(name)`.
48
49
  */
49
50
  export const IDistributedCache = createToken<IDistributedCache>("IDistributedCache") as InjectionToken<IDistributedCache<unknown>>;
51
+
52
+ registerNamedDependencyHint((name) => name === "IDistributedCache"
53
+ ? "The distributed cache is provided by Redis: add redisConnect(redisConfig, { cache: \"distributed\" }) to the @Infra manifest."
54
+ : undefined);
@@ -1,11 +1,11 @@
1
1
  import { DiError } from "./DiError";
2
2
 
3
- /** Returns a hint for a named dependency nobody registers, or `undefined`. */
3
+ /** Returns a hint for a dependency nobody registers (by its name), or `undefined`. */
4
4
  export type NamedDependencyHint = (name: string) => string | undefined;
5
5
 
6
6
  const hints: NamedDependencyHint[] = [];
7
7
 
8
- /** Infrastructure extensions (ORM repositories, …) explain their own named dependencies here. */
8
+ /** Infrastructure extensions (ORM repositories, the cache, …) explain their own dependencies here. */
9
9
  export function registerNamedDependencyHint(hint: NamedDependencyHint): void {
10
10
  hints.push(hint);
11
11
  }
@@ -15,9 +15,10 @@ function hintFor(name: string): string {
15
15
  return hint ? ` ${hint}` : "";
16
16
  }
17
17
 
18
- /** The graph-validation issue for a named dependency nobody registers, naming the service that needs it. */
19
- export function missingNamedDependencyIssue(name: string, requester: string): string {
20
- return `Missing dependency "${name}" for "${requester}".${hintFor(name)}`;
18
+ /** The graph-validation issue for a dependency nobody registers, naming the service that needs it and the fix when one is known. */
19
+ export function missingDependencyIssue(name: string, requester: string): string {
20
+ const hint = hintFor(name);
21
+ return hint === "" ? `Missing dependency "${name}" for "${requester}"` : `Missing dependency "${name}" for "${requester}".${hint}`;
21
22
  }
22
23
 
23
24
  export class NamedDependencyNotFoundError extends DiError {
@@ -1,5 +1,5 @@
1
1
  import { ServiceValidationError } from "../errors";
2
- import { missingNamedDependencyIssue } from "../errors/NamedDependencyNotFoundError";
2
+ import { missingDependencyIssue } from "../errors/NamedDependencyNotFoundError";
3
3
  import { isClassProvider, isLazyDependency, isNamedDependency, isOptionalDependency } from "../provider";
4
4
  import type {
5
5
  KeyedDependency,
@@ -99,7 +99,7 @@ export class GraphValidator {
99
99
  const inner = isLazy || isOptional ? dependency.inner : dependency;
100
100
  // An optional dependency on a name nothing registered is simply absent.
101
101
  if (isNamedDependency(inner) && !this.context.hasName(inner.name)) {
102
- if (!isOptional) issues.add(missingNamedDependencyIssue(inner.name, serviceName(registration)));
102
+ if (!isOptional) issues.add(missingDependencyIssue(inner.name, serviceName(registration)));
103
103
  continue;
104
104
  }
105
105
  const descriptor = this.context.describeDependency(inner);
@@ -109,7 +109,7 @@ export class GraphValidator {
109
109
  }
110
110
  if (!depRegistration) {
111
111
  issues.add(
112
- `Missing dependency "${tokenToDebugName(descriptor.token)}" for "${serviceName(registration)}"`,
112
+ missingDependencyIssue(tokenToDebugName(descriptor.token), serviceName(registration)),
113
113
  );
114
114
  continue;
115
115
  }
@@ -184,16 +184,30 @@ export function validateModuleEncapsulation(
184
184
  // module, say) registered the token.
185
185
  const blocking = consumer.imports.find((imported) => !imported.providedTokens.has(token)
186
186
  && !exportedTokens(imported).has(token) && visibleTokens(imported).has(token));
187
+ const owner = records.find((record) => record !== consumer && record.providedTokens.has(token));
187
188
  if (blocking !== undefined) {
188
- return `which its import "${blocking.name}" receives from its own imports but does not export. Add it to the exports of "${blocking.name}".`;
189
+ // The module that registers and exports the token can be imported
190
+ // directly; an unnamed inner module (`ormBazis`) cannot.
191
+ const direct = owner !== undefined && owner !== blocking && exportedTokens(owner).has(token) && !/^module#\d+$/.test(owner.name);
192
+ return direct
193
+ ? `which its import "${blocking.name}" receives from "${owner.name}" but does not export. Add "${owner.name}" to the imports of "${consumer.name}", or add it to the exports of "${blocking.name}".`
194
+ : `which its import "${blocking.name}" receives from its own imports but does not export. Add it to the exports of "${blocking.name}".`;
189
195
  }
190
- const owner = records.find((record) => record !== consumer && record.providedTokens.has(token));
191
196
  if (owner === undefined) return "which is provided by another module but not exported.";
192
197
  return exportedTokens(owner).has(token)
193
198
  ? `which module "${owner.name}" exports, but "${consumer.name}" does not list "${owner.name}" in its imports. Add "${owner.name}" to the imports of "${consumer.name}".`
194
199
  : `which module "${owner.name}" provides but does not export. Add it to the exports of "${owner.name}".`;
195
200
  };
196
201
 
202
+ // Whether `record` imports `target`, directly or through other modules. Such a
203
+ // module cannot be the fix: importing it into `target` would be a cycle, and a
204
+ // root without exports (AppModule, the kernel root) passes on everything.
205
+ const importsModule = (record: ModuleGraphRecord, target: ModuleGraphRecord, path = new Set<ModuleGraphRecord>()): boolean => {
206
+ if (path.has(record)) return false;
207
+ path.add(record);
208
+ return record.imports.some((imported) => imported === target || importsModule(imported as ModuleGraphRecord, target, path));
209
+ };
210
+
197
211
  // A closed token (`IRepository<Product>`) travels with its family export, so
198
212
  // the fix is named in family terms and skips unnamed inner modules such as
199
213
  // the one `ormBazis` creates.
@@ -203,7 +217,7 @@ export function validateModuleEncapsulation(
203
217
  const exportFix = `${family} (or only ${closed})`;
204
218
  const sees = (record: ModuleGraphRecord) => visibleFamilies(record).has(familyId) || visibleTokens(record).has(token);
205
219
  const passes = (record: ModuleGraphRecord) => exportedFamilies(record).has(familyId) || exportedTokens(record).has(token);
206
- const named = (record: ModuleGraphRecord) => !/^module#\d+$/.test(record.name);
220
+ const named = (record: ModuleGraphRecord) => !/^module#\d+$/.test(record.name) && !importsModule(record, consumer);
207
221
  const blocking = consumer.imports.find((imported) => named(imported) && sees(imported) && !passes(imported));
208
222
  if (blocking !== undefined) {
209
223
  return `which its import "${blocking.name}" receives from its own imports but does not export. Add ${exportFix} to the exports of "${blocking.name}".`;
@@ -379,9 +379,9 @@ function successStatus(method: string, action: ActionMeta, generated: number | u
379
379
 
380
380
  /**
381
381
  * Errors the operation can answer with: 400 when the request carries values
382
- * that are converted or validated (body, list query, query, typed route
383
- * parameters), 401/403 under @Authorize, and the errors the method body
384
- * returns or throws itself.
382
+ * that are converted or validated (body, list query, query and header values
383
+ * and models, typed route parameters), 401/403 under @Authorize, and the
384
+ * errors the method body returns or throws itself.
385
385
  */
386
386
  function errorStatuses(
387
387
  bindings: readonly ParameterBinding[],
@@ -390,7 +390,8 @@ function errorStatuses(
390
390
  ): number[] {
391
391
  const statuses = new Set(generated ?? []);
392
392
  const validated = bindings.some((binding) =>
393
- binding.source === "body" || binding.source === "list" || binding.source === "query"
393
+ binding.source === "body" || binding.source === "list" || binding.source === "query" || binding.source === "header"
394
+ || binding.source === "queryModel" || binding.source === "headerModel"
394
395
  || (binding.source === "route" && binding.type !== undefined && binding.type !== "string"));
395
396
  if (validated) statuses.add(400);
396
397
  if (authorized) {
@@ -2,17 +2,20 @@
2
2
  * Core DI/correlation integration for the pure `@/library/http-client`.
3
3
  *
4
4
  * The HTTP engine lives in the library (framework-agnostic). This module only
5
- * wires it into the runtime: DI tokens, a module factory, and kernel-based
6
- * correlation propagation. All library symbols are re-exported for convenience.
5
+ * wires it into the runtime: a module factory that registers the clients for
6
+ * injection by type, and kernel-based correlation propagation. All library
7
+ * symbols are re-exported for convenience.
7
8
  *
8
9
  * ```ts
9
- * import { httpClientModule, HTTP_CLIENT } from "@/core/http-client";
10
+ * import { HttpClient, httpClientModule } from "@/core/http-client";
10
11
  *
11
- * @Module({ imports: [httpClientModule({ default: { baseUrl: "https://api" } })] })
12
- * class AppModule {}
12
+ * @Module({ imports: [httpClientModule({ default: { baseUrl: "https://api" } })], providers: [scoped(WeatherService)] })
13
+ * class WeatherModule {}
14
+ *
15
+ * class WeatherService { constructor(private readonly http: HttpClient) {} }
13
16
  * ```
14
17
  */
15
- import { DI, Module, createToken, type BazisModule, type ProviderDefinition } from "../di";
18
+ import { DI, Module, type BazisModule, type ProviderDefinition } from "../di";
16
19
  import { getOutboundCorrelationHeaders } from "../kernel";
17
20
  import {
18
21
  HttpClient,
@@ -24,15 +27,10 @@ import {
24
27
 
25
28
  export * from "../../library/http-client";
26
29
 
27
- /** Named-clients factory token. */
28
- export const HTTP_CLIENT_FACTORY = createToken<HttpClientFactory>("IHttpClientFactory");
29
- /** Default injectable {@link HttpClient} token. */
30
- export const HTTP_CLIENT = createToken<HttpClient>("HttpClient");
31
-
32
30
  export interface HttpClientModuleConfig {
33
- /** Named clients resolvable via `HTTP_CLIENT_FACTORY.createClient(name)`. */
31
+ /** Named clients: `HttpClientFactory.createClient(name)`. */
34
32
  readonly clients?: Readonly<Record<string, RequestConfig>>;
35
- /** Defaults for the injectable {@link HTTP_CLIENT} and `createClient()`. */
33
+ /** Defaults for the injectable `HttpClient` and `createClient()`. */
36
34
  readonly default?: RequestConfig;
37
35
  /** Correlation header source. Default: the kernel async request context. */
38
36
  readonly correlationHeaders?: () => HeaderBag;
@@ -45,9 +43,8 @@ export interface HttpClientModuleConfig {
45
43
  /**
46
44
  * Registers the outbound HTTP client in DI with correlation propagation wired
47
45
  * to the kernel request context. A service takes it by type:
48
- * - `HttpClient` (or the {@link HTTP_CLIENT} token) — the default client;
49
- * - `HttpClientFactory` (or {@link HTTP_CLIENT_FACTORY}) — named clients
50
- * (fail-fast on unknown names).
46
+ * - `HttpClient` — the default client;
47
+ * - `HttpClientFactory` — named clients (fail-fast on unknown names).
51
48
  */
52
49
  export function httpClientModule(config: HttpClientModuleConfig = {}): BazisModule {
53
50
  const correlationHeaders = config.correlationHeaders ?? getOutboundCorrelationHeaders;
@@ -85,13 +82,10 @@ export function httpClientModule(config: HttpClientModuleConfig = {}): BazisModu
85
82
  const factory = builder.build();
86
83
 
87
84
  const client = factory.createClient();
88
- // One factory and one default client, under the class (injection by type)
89
- // and under the original tokens.
85
+ // One factory and one default client, injected by type.
90
86
  const providers: ProviderDefinition[] = [
91
87
  DI.singleton(DI.valueProvider(HttpClientFactory, factory)),
92
- DI.singleton(DI.valueProvider(HTTP_CLIENT_FACTORY, factory)),
93
88
  DI.singleton(DI.valueProvider(HttpClient, client)),
94
- DI.singleton(DI.valueProvider(HTTP_CLIENT, client)),
95
89
  ];
96
90
 
97
91
  @Module({ providers })
@@ -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.108.0" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.109.1" }, 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");
@@ -1,23 +1,36 @@
1
- import { HOSTED_SERVICE, createContainer, type BazisModuleRef, type CreateContainerOptions, type DiContainer, type ProviderDefinition } from "../di";
1
+ import { HOSTED_SERVICE, type BazisModuleRef, type DiContainer, type ProviderDefinition } from "../di";
2
2
  import { loadBazisGeneratedRuntime } from "../generatedRuntime";
3
3
  import { HttpServer, type HttpModuleOptions } from "../http";
4
4
  import { KernelBuilder, type Kernel } from "../kernel";
5
5
  import { composeApp } from "../app/runApp";
6
6
  import type { RunAppOptions } from "../app";
7
7
 
8
+ export interface TestContainerOptions {
9
+ /** Replacements for registered providers: `singleton(ITaskStore, FakeStore)`. */
10
+ readonly overrides?: readonly ProviderDefinition[];
11
+ /** Adjust the kernel before it is built: config sources, a logger. */
12
+ readonly configure?: (builder: KernelBuilder) => void;
13
+ }
14
+
8
15
  /**
9
- * A container for module tests. Loads the project's generated code first
10
- * (without it constructor dependencies are missing and a test passes for the
11
- * wrong reason), then builds the graph with the replacements.
16
+ * A container for module tests: the module graph with the kernel services an
17
+ * application has — `Logger`, `EventBus`, `ConfigView` of declared configs,
18
+ * `Environment` (`test`) — but nothing started: no hosted services, no HTTP.
19
+ * Loads the project's generated code first (without it constructor
20
+ * dependencies are missing and a test passes for the wrong reason).
12
21
  *
13
22
  * ```ts
14
23
  * const container = await createTestContainer(TaskModule, { overrides: [singleton(ITaskStore, FakeStore)] });
15
24
  * const tasks = container.createScope().resolve(TaskService);
16
25
  * ```
17
26
  */
18
- export async function createTestContainer(root: BazisModuleRef, options: CreateContainerOptions = {}): Promise<DiContainer> {
27
+ export async function createTestContainer(root: BazisModuleRef, options: TestContainerOptions = {}): Promise<DiContainer> {
19
28
  await loadBazisGeneratedRuntime();
20
- return createContainer(root, { validateOnBuild: true, ...options });
29
+ const builder = new KernelBuilder(root).useOptions({ startupReport: false, environment: "test", signals: [] });
30
+ options.configure?.(builder);
31
+ if (options.overrides !== undefined) builder.useOverrides(options.overrides);
32
+ const kernel: Kernel = await builder.build();
33
+ return kernel.container;
21
34
  }
22
35
 
23
36
  export interface TestAppOptions extends Omit<RunAppOptions, "http"> {
@@ -84,10 +84,25 @@ function redactValue(
84
84
  if (depth >= options.maxDepth) {
85
85
  return options.replacement;
86
86
  }
87
+ // `seen` holds the objects on the current path only: an object reached
88
+ // again through another branch is a shared reference, not a cycle.
87
89
  if (seen.has(value)) {
88
90
  return CIRCULAR;
89
91
  }
90
92
  seen.add(value);
93
+ try {
94
+ return redactObject(value, options, seen, depth);
95
+ } finally {
96
+ seen.delete(value);
97
+ }
98
+ }
99
+
100
+ function redactObject(
101
+ value: object,
102
+ options: NormalizedRedactionOptions,
103
+ seen: WeakSet<object>,
104
+ depth: number,
105
+ ): unknown {
91
106
  if (value instanceof Error) {
92
107
  return redactError(value, options, seen, depth);
93
108
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.108.0",
3
+ "version": "0.109.1",
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",