@praxisjs/di 0.2.3 → 1.1.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.
@@ -1,191 +1,281 @@
1
1
  import "reflect-metadata";
2
2
  import { describe, it, expect, vi } from "vitest";
3
3
 
4
- import { Container, token } from "../container";
4
+ import { Container, container, token } from "../container";
5
5
  import {
6
6
  Injectable,
7
7
  Inject,
8
8
  InjectContainer,
9
- useService,
10
- createScope,
9
+ Scope,
11
10
  } from "../decorators";
12
11
 
13
- // ── Injectable ────────────────────────────────────────────────────────────────
12
+ function makeFieldCtx(name: string) {
13
+ const initializers: Array<(this: unknown) => void> = [];
14
+ return {
15
+ ctx: {
16
+ name,
17
+ kind: "field" as const,
18
+ addInitializer(fn: (this: unknown) => void) { initializers.push(fn); },
19
+ } as ClassFieldDecoratorContext,
20
+ run(instance: unknown) { initializers.forEach((fn) => { fn.call(instance); }); },
21
+ };
22
+ }
23
+
24
+ // ── @Injectable ───────────────────────────────────────────────────────────────
14
25
 
15
26
  describe("Injectable", () => {
27
+ it("with no arguments — defaults to singleton scope", () => {
28
+ class SingletonByDefault {}
29
+ Injectable()(SingletonByDefault, {} as ClassDecoratorContext);
30
+ const a = container.resolve(SingletonByDefault);
31
+ const b = container.resolve(SingletonByDefault);
32
+ expect(a).toBe(b);
33
+ });
34
+
16
35
  it("registers the class in the global container", () => {
17
- // We test via useService resolving without throwing
18
36
  class MyService {
19
- greet() {
20
- return "hello";
21
- }
37
+ greet() { return "hello"; }
22
38
  }
23
39
  Injectable()(MyService, {} as ClassDecoratorContext);
24
- const instance = useService(MyService);
25
- expect(instance).toBeInstanceOf(MyService);
26
- expect(instance.greet()).toBe("hello");
40
+ expect(container.resolve(MyService)).toBeInstanceOf(MyService);
41
+ expect(container.resolve(MyService).greet()).toBe("hello");
27
42
  });
28
43
 
29
- it("registers with transient scope", () => {
44
+ it("registers with transient scope — each resolve returns a new instance", () => {
30
45
  class TmpService {}
31
46
  Injectable({ scope: "transient" })(TmpService, {} as ClassDecoratorContext);
32
- const a = useService(TmpService);
33
- const b = useService(TmpService);
34
- expect(a).not.toBe(b);
47
+ expect(container.resolve(TmpService)).not.toBe(container.resolve(TmpService));
35
48
  });
36
49
  });
37
50
 
38
- // ── Inject ────────────────────────────────────────────────────────────────────
51
+ // ── @Inject ───────────────────────────────────────────────────────────────────
39
52
 
40
53
  describe("Inject", () => {
41
54
  it("injects a registered service via property", () => {
42
55
  class Logger {
43
- log(msg: string) {
44
- return msg;
45
- }
56
+ log(msg: string) { return msg; }
46
57
  }
47
58
  Injectable()(Logger, {} as ClassDecoratorContext);
48
59
 
49
- const initializers: Array<(this: unknown) => void> = [];
50
- const ctx = {
51
- name: "logger",
52
- kind: "field" as const,
53
- addInitializer(fn: (this: unknown) => void) {
54
- initializers.push(fn);
55
- },
56
- } as ClassFieldDecoratorContext;
57
-
60
+ const { ctx, run } = makeFieldCtx("logger");
58
61
  Inject(Logger)(undefined, ctx);
59
-
60
62
  const instance: Record<string, unknown> = {};
61
- initializers.forEach((fn) => {
62
- fn.call(instance);
63
- });
64
-
63
+ run(instance);
65
64
  expect(instance.logger).toBeInstanceOf(Logger);
65
+ // second access covers the cache-hit branch (line 62: !cache.has(this) === false)
66
+ expect(instance.logger).toBe(instance.logger);
66
67
  });
67
68
 
68
69
  it("throws a descriptive error when dep is not registered", () => {
69
70
  class UnknownDep {}
70
-
71
- const initializers: Array<(this: unknown) => void> = [];
72
- const ctx = {
73
- name: "dep",
74
- kind: "field" as const,
75
- addInitializer(fn: (this: unknown) => void) {
76
- initializers.push(fn);
77
- },
78
- } as ClassFieldDecoratorContext;
79
-
71
+ const { ctx, run } = makeFieldCtx("dep");
80
72
  Inject(UnknownDep)(undefined, ctx);
81
-
82
- class MyClass {
83
- constructor() {}
84
- }
73
+ class MyClass {}
85
74
  const instance = new MyClass() as Record<string, unknown>;
86
- initializers.forEach((fn) => {
87
- fn.call(instance);
88
- });
89
-
75
+ run(instance);
90
76
  expect(() => instance.dep).toThrow("[Inject]");
91
77
  });
92
78
 
93
79
  it("warns on direct assignment in non-production", () => {
94
80
  class Dep2 {}
95
81
  Injectable()(Dep2, {} as ClassDecoratorContext);
96
-
97
- const initializers: Array<(this: unknown) => void> = [];
98
- const ctx = {
99
- name: "dep2",
100
- kind: "field" as const,
101
- addInitializer(fn: (this: unknown) => void) {
102
- initializers.push(fn);
103
- },
104
- } as ClassFieldDecoratorContext;
105
-
82
+ const { ctx, run } = makeFieldCtx("dep2");
106
83
  Inject(Dep2)(undefined, ctx);
107
84
  const instance: Record<string, unknown> = {};
108
- initializers.forEach((fn) => {
109
- fn.call(instance);
110
- });
111
-
85
+ run(instance);
112
86
  const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
113
87
  instance.dep2 = "override";
114
88
  expect(spy).toHaveBeenCalledWith(expect.stringContaining("[Inject]"));
115
89
  spy.mockRestore();
116
90
  });
91
+
92
+ it("returns cached value on second access (cache hit)", () => {
93
+ class CachedService {
94
+ id = Math.random();
95
+ }
96
+ Injectable()(CachedService, {} as ClassDecoratorContext);
97
+
98
+ const { ctx, run } = makeFieldCtx("svc");
99
+ Inject(CachedService)(undefined, ctx);
100
+ const instance: Record<string, unknown> = {};
101
+ run(instance);
102
+
103
+ const first = instance.svc;
104
+ const second = instance.svc;
105
+ expect(first).toBe(second); // same cached instance
106
+ });
107
+
108
+ it("does not warn on direct assignment in production mode", () => {
109
+ class ProdDep {}
110
+ Injectable()(ProdDep, {} as ClassDecoratorContext);
111
+ const { ctx, run } = makeFieldCtx("dep");
112
+ Inject(ProdDep)(undefined, ctx);
113
+ const instance: Record<string, unknown> = {};
114
+ run(instance);
115
+
116
+ const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
117
+ vi.stubEnv("NODE_ENV", "production");
118
+ instance.dep = "override";
119
+ expect(spy).not.toHaveBeenCalled();
120
+
121
+ vi.unstubAllEnvs();
122
+ spy.mockRestore();
123
+ });
124
+
125
+ it("includes the Token description in the error when resolution fails", () => {
126
+ const MY_TOKEN = token<string>("MY_SERVICE");
127
+ const { ctx, run } = makeFieldCtx("svc");
128
+ Inject(MY_TOKEN)(undefined, ctx);
129
+ class Consumer {}
130
+ const instance = new Consumer() as Record<string, unknown>;
131
+ run(instance);
132
+ expect(() => instance.svc).toThrow("MY_SERVICE");
133
+ });
134
+
135
+ it("@Inject on an anonymous class — error message uses token description instead of empty class name", () => {
136
+ const ANON_TOKEN = token<string>("ANON_TOKEN");
137
+ const { ctx, run } = makeFieldCtx("val");
138
+ Inject(ANON_TOKEN)(undefined, ctx);
139
+ const instance: Record<string, unknown> = {};
140
+ run(instance);
141
+ expect(() => instance.val).toThrow("ANON_TOKEN");
142
+ });
117
143
  });
118
144
 
119
- // ── InjectContainer ───────────────────────────────────────────────────────────
145
+ // ── @InjectContainer ──────────────────────────────────────────────────────────
120
146
 
121
147
  describe("InjectContainer", () => {
122
- it("injects the global container instance", () => {
123
- const initializers: Array<(this: unknown) => void> = [];
124
- const ctx = {
125
- name: "container",
126
- kind: "field" as const,
127
- addInitializer(fn: (this: unknown) => void) {
128
- initializers.push(fn);
129
- },
130
- } as ClassFieldDecoratorContext;
131
-
148
+ it("injects the global container on an unscoped class", () => {
149
+ const { ctx, run } = makeFieldCtx("c");
132
150
  InjectContainer()(undefined, ctx);
133
151
  const instance: Record<string, unknown> = {};
134
- initializers.forEach((fn) => {
135
- fn.call(instance);
136
- });
152
+ run(instance);
153
+ expect(instance.c).toBeInstanceOf(Container);
154
+ expect(instance.c).toBe(container);
155
+ });
137
156
 
138
- expect(instance.container).toBeInstanceOf(Container);
157
+ it("called multiple times on same instance — returns same container instance (cached)", () => {
158
+ const { ctx, run } = makeFieldCtx("c");
159
+ InjectContainer()(undefined, ctx);
160
+ const instance: Record<string, unknown> = {};
161
+ run(instance);
162
+ const first = instance.c;
163
+ const second = instance.c;
164
+ expect(first).toBe(second);
139
165
  });
140
166
  });
141
167
 
142
- // ── useService ────────────────────────────────────────────────────────────────
168
+ // ── @Scope ────────────────────────────────────────────────────────────────────
169
+
170
+ describe("Scope", () => {
171
+ it("creates a child container per instance", () => {
172
+ @Scope()
173
+ class Widget {}
174
+ const { ctx, run } = makeFieldCtx("c");
175
+ InjectContainer()(undefined, ctx);
176
+
177
+ const a = new Widget() as Record<string, unknown>;
178
+ run(a);
179
+ const b = new Widget() as Record<string, unknown>;
180
+ run(b);
181
+
182
+ expect(a.c).toBeInstanceOf(Container);
183
+ expect(b.c).toBeInstanceOf(Container);
184
+ expect(a.c).not.toBe(b.c); // each instance gets its own scope
185
+ expect(a.c).not.toBe(container); // it's a child, not the root
186
+ });
187
+
188
+ it("services registered in configure are resolvable within the scope", () => {
189
+ const DB_URL = token<string>("DB_URL");
190
+
191
+ @Scope((c) => { c.registerValue(DB_URL, "postgres://localhost"); })
192
+ class DataModule {}
143
193
 
144
- describe("useService", () => {
145
- it("resolves by Token", () => {
194
+ const { ctx, run } = makeFieldCtx("db");
195
+ Inject(DB_URL)(undefined, ctx);
196
+
197
+ const instance = new DataModule() as Record<string, unknown>;
198
+ run(instance);
199
+
200
+ expect(instance.db).toBe("postgres://localhost");
201
+ });
202
+
203
+ it("@Inject resolves from the scoped container, not the global one", () => {
204
+ class ScopedRepo {}
205
+ // NOT registered in global container
146
206
  const VERSION = token<string>("VERSION");
147
- const c = createScope((sc) => sc.registerValue(VERSION, "1.0.0"));
148
- // useService uses global container — test via token on a fresh child
149
- expect(c.resolve(VERSION)).toBe("1.0.0");
207
+
208
+ @Scope((c) => {
209
+ c.register(ScopedRepo);
210
+ c.registerValue(VERSION, "2.0");
211
+ })
212
+ class App {}
213
+
214
+ const { ctx: repoCtx, run: runRepo } = makeFieldCtx("repo");
215
+ Inject(ScopedRepo)(undefined, repoCtx);
216
+ const { ctx: verCtx, run: runVer } = makeFieldCtx("version");
217
+ Inject(VERSION)(undefined, verCtx);
218
+
219
+ const instance = new App() as Record<string, unknown>;
220
+ runRepo(instance);
221
+ runVer(instance);
222
+
223
+ expect(instance.repo).toBeInstanceOf(ScopedRepo);
224
+ expect(instance.version).toBe("2.0");
150
225
  });
151
- });
152
226
 
153
- // ── createScope ───────────────────────────────────────────────────────────────
227
+ it("@InjectContainer returns the scoped child container", () => {
228
+ @Scope((c) => { c.registerValue(token<number>("X"), 42); })
229
+ class Service {}
230
+
231
+ const { ctx, run } = makeFieldCtx("container");
232
+ InjectContainer()(undefined, ctx);
233
+
234
+ const instance = new Service() as Record<string, unknown>;
235
+ run(instance);
154
236
 
155
- describe("createScope", () => {
156
- it("creates a child container without configure fn", () => {
157
- const child = createScope();
158
- expect(child).toBeInstanceOf(Container);
237
+ expect(instance.container).toBeInstanceOf(Container);
238
+ expect(instance.container).not.toBe(container);
159
239
  });
160
240
 
161
- it("applies the configure callback", () => {
162
- const DB = token<string>("DB");
163
- const child = createScope((c) =>
164
- c.registerValue(DB, "postgres://localhost"),
165
- );
166
- expect(child.resolve(DB)).toBe("postgres://localhost");
241
+ it("child container inherits registrations from the parent", () => {
242
+ class GlobalService {}
243
+ Injectable()(GlobalService, {} as ClassDecoratorContext);
244
+
245
+ @Scope()
246
+ class Child {}
247
+
248
+ const { ctx, run } = makeFieldCtx("svc");
249
+ Inject(GlobalService)(undefined, ctx);
250
+ const instance = new Child() as Record<string, unknown>;
251
+ run(instance);
252
+
253
+ expect(instance.svc).toBeInstanceOf(GlobalService);
167
254
  });
168
- });
169
255
 
170
- // ── Inject — Token in error message ──────────────────────────────────────────
256
+ it("@Scope on child class that inherits from @Scope parent — child gets own container", () => {
257
+ const PARENT_TOKEN = token<string>("PARENT_VAL");
258
+ const CHILD_TOKEN = token<string>("CHILD_VAL");
171
259
 
172
- describe("Inject — Token error display", () => {
173
- it("includes the Token description in the error message when resolution fails", () => {
174
- const MY_TOKEN = token<string>("MY_SERVICE");
260
+ @Scope((c) => { c.registerValue(PARENT_TOKEN, "parent"); })
261
+ class Parent {}
175
262
 
176
- const initializers: Array<(this: unknown) => void> = [];
177
- const ctx = {
178
- name: "svc",
179
- kind: "field" as const,
180
- addInitializer(fn: (this: unknown) => void) { initializers.push(fn); },
181
- } as ClassFieldDecoratorContext;
263
+ @Scope((c) => { c.registerValue(CHILD_TOKEN, "child"); })
264
+ class Child extends (Parent as unknown as new () => object) {}
182
265
 
183
- Inject(MY_TOKEN)(undefined, ctx);
266
+ const { ctx: ctxC, run: runC } = makeFieldCtx("c");
267
+ InjectContainer()(undefined, ctxC);
184
268
 
185
- class Consumer { constructor() {} }
186
- const instance = new Consumer() as Record<string, unknown>;
187
- initializers.forEach((fn) => { fn.call(instance); });
269
+ const parentInstance = new Parent() as Record<string, unknown>;
270
+ runC(parentInstance);
188
271
 
189
- expect(() => instance.svc).toThrow("MY_SERVICE");
272
+ const childInstance = new Child() as Record<string, unknown>;
273
+ runC(childInstance);
274
+
275
+ expect(parentInstance.c).toBeInstanceOf(Container);
276
+ expect(childInstance.c).toBeInstanceOf(Container);
277
+ // child has its own scoped container, different from the parent's
278
+ expect(childInstance.c).not.toBe(parentInstance.c);
279
+ expect(childInstance.c).not.toBe(container);
190
280
  });
191
281
  });
package/src/container.ts CHANGED
@@ -10,7 +10,8 @@ export interface ServiceDescriptor {
10
10
  instance?: unknown;
11
11
  }
12
12
 
13
- export type Constructor<T = unknown> = new (...args: unknown[]) => T;
13
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
14
+ export type Constructor<T = unknown> = new (...args: any[]) => T;
14
15
 
15
16
  export class Token<_T> {
16
17
  readonly description: string;
@@ -58,38 +59,70 @@ export class Container {
58
59
  return this;
59
60
  }
60
61
 
61
- resolve<T>(target: Constructor<T> | Token<T>): T {
62
- const entry = this.services.get(target as Constructor | Token<unknown>);
63
-
64
- if (target instanceof Token) {
65
- if (entry !== undefined) return entry as T;
66
- if (this.parent) return this.parent.resolve(target);
67
- throw new Error(`[DI] Token not registered: ${target.toString()}`);
68
- }
69
-
70
- if (!entry) {
71
- if (this.parent) return this.parent.resolve(target);
72
- throw new Error(
73
- `[DI] Service not registered: ${(target as Constructor).name}`,
74
- );
62
+ resolve<T>(
63
+ target: Constructor<T> | Token<T>,
64
+ resolutionStack = new Set<Constructor | Token<unknown>>(),
65
+ ): T {
66
+ const key = target as Constructor | Token<unknown>;
67
+
68
+ if (resolutionStack.has(key)) {
69
+ const chain = [...resolutionStack, key]
70
+ .map((k) =>
71
+ k instanceof Token ? k.toString() : (k).name,
72
+ )
73
+ .join(" → ");
74
+ throw new Error(`[DI] Circular dependency detected: ${chain}`);
75
75
  }
76
76
 
77
- const descriptor = entry as ServiceDescriptor;
78
-
79
- if (descriptor.scope === "singleton") {
80
- descriptor.instance ??= this.instantiate(descriptor.target);
81
- return descriptor.instance as T;
77
+ resolutionStack.add(key);
78
+ try {
79
+ const entry = this.services.get(key);
80
+
81
+ if (target instanceof Token) {
82
+ if (entry !== undefined) return entry as T;
83
+ if (this.parent) return this.parent.resolve(target);
84
+ throw new Error(`[DI] Token not registered: ${target.toString()}`);
85
+ }
86
+
87
+ if (!entry) {
88
+ if (this.parent) return this.parent.resolve(target);
89
+ throw new Error(
90
+ `[DI] Service not registered: ${(target as Constructor).name}`,
91
+ );
92
+ }
93
+
94
+ const descriptor = entry as ServiceDescriptor;
95
+
96
+ if (descriptor.scope === "singleton") {
97
+ descriptor.instance ??= this.instantiate(descriptor.target, resolutionStack);
98
+ return descriptor.instance as T;
99
+ }
100
+
101
+ return this.instantiate(descriptor.target, resolutionStack) as T;
102
+ } finally {
103
+ resolutionStack.delete(key);
82
104
  }
83
-
84
- return this.instantiate(descriptor.target) as T;
85
105
  }
86
106
 
87
- private instantiate<T>(target: Constructor<T>): T {
107
+ private instantiate<T>(
108
+ target: Constructor<T>,
109
+ resolutionStack = new Set<Constructor | Token<unknown>>(),
110
+ ): T {
88
111
  const deps: Array<Constructor | Token<unknown>> =
89
112
  (Reflect.getMetadata("di:inject", target) as Array<Constructor | Token<unknown>> | undefined) ?? [];
90
113
 
91
- const resolvedDeps = deps.map((dep) => this.resolve(dep as Constructor));
92
- const instance = new target(...resolvedDeps);
114
+ const resolvedDeps = deps.map((dep) =>
115
+ this.resolve(dep as Constructor, resolutionStack),
116
+ );
117
+ let instance: T;
118
+ try {
119
+ instance = new target(...resolvedDeps);
120
+ } catch (err) {
121
+ if (err instanceof Error && err.message.startsWith("[DI]")) throw err;
122
+ throw new Error(
123
+ `[DI] Failed to instantiate "${target.name}": ${(err as Error).message}`,
124
+ );
125
+ }
93
126
 
94
127
  const rawPropInjections: unknown = Reflect.getMetadata("di:props", target.prototype as object);
95
128
  const propInjections =
@@ -98,6 +131,7 @@ export class Container {
98
131
  for (const [prop, dep] of propInjections) {
99
132
  (instance as Record<string, unknown>)[prop] = this.resolve(
100
133
  dep as Constructor,
134
+ resolutionStack,
101
135
  );
102
136
  }
103
137