bazis 0.97.11 → 0.98.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -238,9 +238,16 @@ function normalizeAbsolutePath(path: string): string {
238
238
  * The root module stays clean: `@Module({ imports: [UsersModule] })`.
239
239
  */
240
240
  export async function runApp(root: BazisModuleRef, options: RunAppOptions = {}): Promise<number> {
241
- const configure = options.configure;
242
241
  await loadBazisGeneratedRuntime();
242
+ return Bazis.run(composeApp(root, options), options.kernel, options.configure);
243
+ }
243
244
 
245
+ /**
246
+ * @internal The application root `runApp` starts: infrastructure, cache and
247
+ * configs next to the feature root, wrapped in the HTTP/gRPC/UI hosts the
248
+ * options ask for. Shared with `startTestApp`, so tests run the same graph.
249
+ */
250
+ export function composeApp(root: BazisModuleRef, options: RunAppOptions = {}): BazisModuleRef {
244
251
  // Infrastructure and cache are global modules; add them to the graph next to the feature root.
245
252
  const globals: BazisModuleRef[] = [];
246
253
  if (options.infra !== undefined) {
@@ -277,17 +284,15 @@ export async function runApp(root: BazisModuleRef, options: RunAppOptions = {}):
277
284
  validator,
278
285
  imports: [httpAppRoot],
279
286
  });
280
- const runtimeRoot = options.grpc === undefined ? httpRoot : {
287
+ return options.grpc === undefined ? httpRoot : {
281
288
  imports: [httpRoot, grpcModule({ ...options.grpc, validator: options.grpc.validator === undefined ? validator : options.grpc.validator, imports: [appRoot, ...(options.grpc.imports ?? [])] })],
282
289
  };
283
- return Bazis.run(runtimeRoot, options.kernel, configure);
284
290
  }
285
291
 
286
292
  if (options.validator) {
287
293
  useModelValidator(options.validator);
288
294
  }
289
295
  const runtimeRoot = withUiRuntime(appRoot, options.ui, undefined);
290
- const transportRoot = options.grpc === undefined ? runtimeRoot
296
+ return options.grpc === undefined ? runtimeRoot
291
297
  : grpcModule({ ...options.grpc, validator: options.grpc.validator === undefined ? options.validator ?? modelValidatorAdapter : options.grpc.validator, imports: [runtimeRoot, ...(options.grpc.imports ?? [])] });
292
- return Bazis.run(transportRoot, options.kernel, configure);
293
298
  }
package/core/di/index.ts CHANGED
@@ -22,6 +22,7 @@ export {
22
22
  } from "./errors";
23
23
  export {
24
24
  createContainer,
25
+ type CreateContainerOptions,
25
26
  collectModuleControllers,
26
27
  collectModuleConfigs,
27
28
  collectModuleUiProfiles,
@@ -4,9 +4,9 @@ import { HOSTED_SERVICE, 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";
7
- import { ModuleEncapsulationError } from "../errors";
7
+ import { DiError, ModuleEncapsulationError } from "../errors";
8
8
  import { OpenGenericRegistration } from "../internal/OpenGenericRegistration";
9
- import type { Class } from "../token";
9
+ import { tokenToDebugName, type Class } from "../token";
10
10
  import type { BuildServiceProviderOptions } from "../types";
11
11
  import { DI } from "./DI";
12
12
  import { Global } from "./Global";
@@ -36,7 +36,17 @@ function moduleDiagnosticName(moduleRef: BazisModuleRef, fallbackIndex: number):
36
36
  return `module#${fallbackIndex}`;
37
37
  }
38
38
 
39
- export function createContainer(rootModule: BazisModuleRef, options?: BuildServiceProviderOptions): DiContainer {
39
+ export interface CreateContainerOptions extends BuildServiceProviderOptions {
40
+ /**
41
+ * Replacements for registered providers, for tests: `singleton(ITaskStore,
42
+ * FakeStore)`. They are registered after the whole module graph, so they win
43
+ * regardless of `imports` order, and they are visible to every module. Each
44
+ * one must replace an existing registration of its token (and key).
45
+ */
46
+ readonly overrides?: readonly ProviderDefinition[];
47
+ }
48
+
49
+ export function createContainer(rootModule: BazisModuleRef, options?: CreateContainerOptions): DiContainer {
40
50
  // The container itself is injectable (SERVICE_PROVIDER, .NET-style). The
41
51
  // instance does not exist until the end of this function, so the factory
42
52
  // closes over a ref that is assigned right after construction.
@@ -187,6 +197,9 @@ export function createContainer(rootModule: BazisModuleRef, options?: BuildServi
187
197
 
188
198
  loadModule(diInfraModule);
189
199
  loadModule(rootModule);
200
+ if (options?.overrides !== undefined && options.overrides.length > 0) {
201
+ loadModule(overridesModule(rootModule, options.overrides, collection.toArray()));
202
+ }
190
203
  const recordList = Array.from(records.values());
191
204
  const collectedDefinitions = collection.toArray();
192
205
  const classProviderHooks = collectClassProviderHooks(collectedDefinitions);
@@ -247,6 +260,30 @@ export function createContainer(rootModule: BazisModuleRef, options?: BuildServi
247
260
  return built;
248
261
  }
249
262
 
263
+ /**
264
+ * A global module loaded last: its registrations win and are exported to every
265
+ * module. It imports the root, so a replacement may use the root's exports.
266
+ */
267
+ function overridesModule(
268
+ rootModule: BazisModuleRef,
269
+ overrides: readonly ProviderDefinition[],
270
+ registered: readonly ProviderDefinition[],
271
+ ): BazisModuleRef {
272
+ for (const override of overrides) {
273
+ const token = override.provider.provide;
274
+ if (!registered.some((definition) => definition.provider.provide === token && definition.key === override.key)) {
275
+ const subject = override.key === undefined ? `"${tokenToDebugName(token)}"` : `"${tokenToDebugName(token)}" with key ${JSON.stringify(String(override.key))}`;
276
+ throw new DiError(`Override of ${subject} replaces nothing: no module registers it. Check the token, or register it in a module first.`);
277
+ }
278
+ }
279
+ return {
280
+ imports: [rootModule],
281
+ providers: overrides,
282
+ exports: [...new Set(overrides.map((override) => override.provider.provide))],
283
+ global: true,
284
+ };
285
+ }
286
+
250
287
  function normalizeLateGeneratedClassDeps(definition: ProviderDefinition): ProviderDefinition {
251
288
  const provider = definition.provider;
252
289
  if (!isClassProvider(provider) || provider.deps !== undefined || getClassDeps(provider.useClass) !== undefined) {
@@ -25,7 +25,7 @@ export {
25
25
  type ModuleOwnedProviderContributionContext,
26
26
  type ModuleOwnedProviderContributor,
27
27
  } from "./moduleOwnedProviderContributors";
28
- export { createContainer } from "./createContainer";
28
+ export { createContainer, type CreateContainerOptions } from "./createContainer";
29
29
  export { collectModuleControllers } from "./collectModuleControllers";
30
30
  export { collectModuleConfigs } from "./collectModuleConfigs";
31
31
  export { collectModuleUiProfiles } from "./collectModuleUiProfiles";
@@ -165,12 +165,14 @@ async function loadRuntimeOnce(): Promise<void> {
165
165
 
166
166
  /**
167
167
  * The project's generated module `name`, or undefined when it does not exist.
168
- * Framework sources inside the project (`src/bazis`) find `src/generated`
169
- * next to them; an installed package (`node_modules/bazis`) cannot, so the
170
- * project root is the working directory, as for `bazis dev` and `bazis test`.
168
+ * The project root is the working directory, as for `bazis dev` and `bazis
169
+ * test`; it comes first, because a framework linked from another checkout
170
+ * (`bazis new --link-framework`) would otherwise find that checkout's own
171
+ * `src/generated` next to its sources. Framework sources inside the project
172
+ * (`src/bazis`) find the same directory either way.
171
173
  */
172
174
  async function importGeneratedModule<T>(name: string): Promise<T | undefined> {
173
- const candidates = [`../../generated/bazis/${name}`, path.join(process.cwd(), "src/generated/bazis", name)];
175
+ const candidates = [path.join(process.cwd(), "src/generated/bazis", name), `../../generated/bazis/${name}`];
174
176
  for (const candidate of candidates) {
175
177
  try {
176
178
  return (await import(candidate)) as T;
@@ -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.97.11" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.98.0" }, 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,4 +1,4 @@
1
- import { DI, Global, Module, collectModuleConfigs, createContainer, type BazisModule, type BazisModuleRef } from "../di";
1
+ import { DI, Global, Module, collectModuleConfigs, createContainer, type BazisModule, type BazisModuleRef, type ProviderDefinition } from "../di";
2
2
  import { isBazisModuleClass } from "../di/module/Module";
3
3
  import { ApplicationLifetime } from "./ApplicationLifetime";
4
4
  import { Configuration, loadConfiguration } from "./config/Configuration";
@@ -44,6 +44,7 @@ export class KernelBuilder {
44
44
  private signals: readonly NodeJS.Signals[] = DEFAULT_SIGNALS;
45
45
  private unhandledErrorPolicy: UnhandledErrorPolicy = "shutdown";
46
46
  private validateOnBuild = true;
47
+ private overrides: readonly ProviderDefinition[] = [];
47
48
  private startupReport?: boolean;
48
49
  private logger?: Logger;
49
50
 
@@ -100,6 +101,15 @@ export class KernelBuilder {
100
101
  return this;
101
102
  }
102
103
 
104
+ /**
105
+ * Replacements for registered providers, for tests (see `createContainer`
106
+ * `overrides`): registered after the whole graph and visible to every module.
107
+ */
108
+ public useOverrides(overrides: readonly ProviderDefinition[]): this {
109
+ this.overrides = [...this.overrides, ...overrides];
110
+ return this;
111
+ }
112
+
103
113
  /** Sources are merged in registration order: later sources override earlier ones. */
104
114
  public addConfigSource(source: ConfigSource): this {
105
115
  this.configSources.push(source);
@@ -152,7 +162,7 @@ export class KernelBuilder {
152
162
  const kernelRootModule = BazisKernelModule;
153
163
 
154
164
  const containerStartedAt = performance.now();
155
- const container = createContainer(kernelRootModule, { validateOnBuild: this.validateOnBuild });
165
+ const container = createContainer(kernelRootModule, { validateOnBuild: this.validateOnBuild, overrides: this.overrides });
156
166
  const containerMs = performance.now() - containerStartedAt;
157
167
 
158
168
  const timings: KernelTimings = {
@@ -0,0 +1,75 @@
1
+ import { HOSTED_SERVICE, createContainer, type BazisModuleRef, type CreateContainerOptions, type DiContainer, type ProviderDefinition } from "../di";
2
+ import { loadBazisGeneratedRuntime } from "../generatedRuntime";
3
+ import { HttpServer, type HttpModuleOptions } from "../http";
4
+ import { KernelBuilder, type Kernel } from "../kernel";
5
+ import { composeApp } from "../app/runApp";
6
+ import type { RunAppOptions } from "../app";
7
+
8
+ /**
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.
12
+ *
13
+ * ```ts
14
+ * const container = await createTestContainer(TaskModule, { overrides: [singleton(ITaskStore, FakeStore)] });
15
+ * const tasks = container.createScope().resolve(TaskService);
16
+ * ```
17
+ */
18
+ export async function createTestContainer(root: BazisModuleRef, options: CreateContainerOptions = {}): Promise<DiContainer> {
19
+ await loadBazisGeneratedRuntime();
20
+ return createContainer(root, { validateOnBuild: true, ...options });
21
+ }
22
+
23
+ export interface TestAppOptions extends Omit<RunAppOptions, "http"> {
24
+ /** HTTP options; the test app always listens on 127.0.0.1 and a free port. `false`: no HTTP server. */
25
+ readonly http?: Omit<HttpModuleOptions, "port" | "hostname"> | false;
26
+ /** Replacements for registered providers, see `createContainer` `overrides`. */
27
+ readonly overrides?: readonly ProviderDefinition[];
28
+ }
29
+
30
+ export interface TestApp {
31
+ /** `http://127.0.0.1:<port>`; empty without an HTTP server. */
32
+ readonly url: string;
33
+ /** `fetch` relative to {@link url}: `app.fetch("/tasks/1")`. */
34
+ fetch(path: string, init?: RequestInit): Promise<Response>;
35
+ /** The application container: `app.container.createScope().resolve(TaskService)`. */
36
+ readonly container: DiContainer;
37
+ /** Graceful stop, like SIGTERM in a real run. */
38
+ stop(): Promise<void>;
39
+ }
40
+
41
+ /**
42
+ * Starts the application in this process the way `runApp` does, for tests:
43
+ * environment `test`, a free port, no signal handlers, no startup report.
44
+ * Configuration errors and failing startup throw instead of exiting the process.
45
+ *
46
+ * ```ts
47
+ * const app = await startTestApp(AppModule, { overrides: [singleton(ITaskStore, FakeStore)] });
48
+ * try {
49
+ * expect((await app.fetch("/tasks/1")).status).toBe(200);
50
+ * } finally {
51
+ * await app.stop();
52
+ * }
53
+ * ```
54
+ */
55
+ export async function startTestApp(root: BazisModuleRef, options: TestAppOptions = {}): Promise<TestApp> {
56
+ await loadBazisGeneratedRuntime();
57
+ const { http, overrides, configure, kernel: kernelOptions, ...rest } = options;
58
+ const appRoot = composeApp(root, { ...rest, http: http === false ? undefined : { ...http, hostname: "127.0.0.1", port: 0 } });
59
+ const builder = new KernelBuilder(appRoot).useOptions({ startupReport: false, ...kernelOptions, environment: kernelOptions?.environment ?? "test", signals: [] });
60
+ configure?.(builder);
61
+ if (overrides !== undefined) builder.useOverrides(overrides);
62
+ const kernel: Kernel = await builder.build();
63
+ await kernel.start();
64
+ const server = kernel.container.resolveAll(HOSTED_SERVICE).find((service): service is HttpServer => service instanceof HttpServer);
65
+ const url = server ? `http://127.0.0.1:${server.port}` : "";
66
+ return {
67
+ url,
68
+ container: kernel.container,
69
+ fetch: (path, init) => {
70
+ if (!url) throw new Error("startTestApp was started with http: false.");
71
+ return fetch(`${url}${path}`, init);
72
+ },
73
+ stop: () => kernel.stop(),
74
+ };
75
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.97.11",
3
+ "version": "0.98.0",
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",
@@ -41,6 +41,7 @@
41
41
  "./core/infra": "./core/infra/index.ts",
42
42
  "./core/kernel": "./core/kernel/index.ts",
43
43
  "./core/orm": "./core/orm/index.ts",
44
+ "./core/testing": "./core/testing/index.ts",
44
45
  "./core/websocket": "./core/websocket/index.ts",
45
46
  "./library/boundary": "./library/boundary/index.ts",
46
47
  "./library/http-client": "./library/http-client/index.ts",