@praxisjs/di 0.2.2 → 1.0.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,173 +1,228 @@
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
- // Each test group uses a fresh container to avoid state pollution
14
- function makeScope() {
15
- return new Container();
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
+ };
16
22
  }
17
23
 
18
- // ── Injectable ────────────────────────────────────────────────────────────────
24
+ // ── @Injectable ───────────────────────────────────────────────────────────────
19
25
 
20
26
  describe("Injectable", () => {
21
27
  it("registers the class in the global container", () => {
22
- // We test via useService resolving without throwing
23
28
  class MyService {
24
- greet() {
25
- return "hello";
26
- }
29
+ greet() { return "hello"; }
27
30
  }
28
31
  Injectable()(MyService, {} as ClassDecoratorContext);
29
- const instance = useService(MyService);
30
- expect(instance).toBeInstanceOf(MyService);
31
- expect(instance.greet()).toBe("hello");
32
+ expect(container.resolve(MyService)).toBeInstanceOf(MyService);
33
+ expect(container.resolve(MyService).greet()).toBe("hello");
32
34
  });
33
35
 
34
- it("registers with transient scope", () => {
36
+ it("registers with transient scope — each resolve returns a new instance", () => {
35
37
  class TmpService {}
36
38
  Injectable({ scope: "transient" })(TmpService, {} as ClassDecoratorContext);
37
- const a = useService(TmpService);
38
- const b = useService(TmpService);
39
- expect(a).not.toBe(b);
39
+ expect(container.resolve(TmpService)).not.toBe(container.resolve(TmpService));
40
40
  });
41
41
  });
42
42
 
43
- // ── Inject ────────────────────────────────────────────────────────────────────
43
+ // ── @Inject ───────────────────────────────────────────────────────────────────
44
44
 
45
45
  describe("Inject", () => {
46
46
  it("injects a registered service via property", () => {
47
47
  class Logger {
48
- log(msg: string) {
49
- return msg;
50
- }
48
+ log(msg: string) { return msg; }
51
49
  }
52
50
  Injectable()(Logger, {} as ClassDecoratorContext);
53
51
 
54
- const initializers: Array<(this: unknown) => void> = [];
55
- const ctx = {
56
- name: "logger",
57
- kind: "field" as const,
58
- addInitializer(fn: (this: unknown) => void) {
59
- initializers.push(fn);
60
- },
61
- } as ClassFieldDecoratorContext;
62
-
52
+ const { ctx, run } = makeFieldCtx("logger");
63
53
  Inject(Logger)(undefined, ctx);
64
-
65
54
  const instance: Record<string, unknown> = {};
66
- initializers.forEach((fn) => {
67
- fn.call(instance);
68
- });
69
-
55
+ run(instance);
70
56
  expect(instance.logger).toBeInstanceOf(Logger);
57
+ // second access covers the cache-hit branch (line 62: !cache.has(this) === false)
58
+ expect(instance.logger).toBe(instance.logger);
71
59
  });
72
60
 
73
61
  it("throws a descriptive error when dep is not registered", () => {
74
62
  class UnknownDep {}
75
-
76
- const initializers: Array<(this: unknown) => void> = [];
77
- const ctx = {
78
- name: "dep",
79
- kind: "field" as const,
80
- addInitializer(fn: (this: unknown) => void) {
81
- initializers.push(fn);
82
- },
83
- } as ClassFieldDecoratorContext;
84
-
63
+ const { ctx, run } = makeFieldCtx("dep");
85
64
  Inject(UnknownDep)(undefined, ctx);
86
-
87
- class MyClass {
88
- constructor() {}
89
- }
65
+ class MyClass {}
90
66
  const instance = new MyClass() as Record<string, unknown>;
91
- initializers.forEach((fn) => {
92
- fn.call(instance);
93
- });
94
-
67
+ run(instance);
95
68
  expect(() => instance.dep).toThrow("[Inject]");
96
69
  });
97
70
 
98
71
  it("warns on direct assignment in non-production", () => {
99
72
  class Dep2 {}
100
73
  Injectable()(Dep2, {} as ClassDecoratorContext);
101
-
102
- const initializers: Array<(this: unknown) => void> = [];
103
- const ctx = {
104
- name: "dep2",
105
- kind: "field" as const,
106
- addInitializer(fn: (this: unknown) => void) {
107
- initializers.push(fn);
108
- },
109
- } as ClassFieldDecoratorContext;
110
-
74
+ const { ctx, run } = makeFieldCtx("dep2");
111
75
  Inject(Dep2)(undefined, ctx);
112
76
  const instance: Record<string, unknown> = {};
113
- initializers.forEach((fn) => {
114
- fn.call(instance);
115
- });
116
-
77
+ run(instance);
117
78
  const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
118
79
  instance.dep2 = "override";
119
80
  expect(spy).toHaveBeenCalledWith(expect.stringContaining("[Inject]"));
120
81
  spy.mockRestore();
121
82
  });
83
+
84
+ it("returns cached value on second access (cache hit)", () => {
85
+ class CachedService {
86
+ id = Math.random();
87
+ }
88
+ Injectable()(CachedService, {} as ClassDecoratorContext);
89
+
90
+ const { ctx, run } = makeFieldCtx("svc");
91
+ Inject(CachedService)(undefined, ctx);
92
+ const instance: Record<string, unknown> = {};
93
+ run(instance);
94
+
95
+ const first = instance.svc;
96
+ const second = instance.svc;
97
+ expect(first).toBe(second); // same cached instance
98
+ });
99
+
100
+ it("does not warn on direct assignment in production mode", () => {
101
+ class ProdDep {}
102
+ Injectable()(ProdDep, {} as ClassDecoratorContext);
103
+ const { ctx, run } = makeFieldCtx("dep");
104
+ Inject(ProdDep)(undefined, ctx);
105
+ const instance: Record<string, unknown> = {};
106
+ run(instance);
107
+
108
+ const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
109
+ vi.stubEnv("NODE_ENV", "production");
110
+ instance.dep = "override";
111
+ expect(spy).not.toHaveBeenCalled();
112
+
113
+ vi.unstubAllEnvs();
114
+ spy.mockRestore();
115
+ });
116
+
117
+ it("includes the Token description in the error when resolution fails", () => {
118
+ const MY_TOKEN = token<string>("MY_SERVICE");
119
+ const { ctx, run } = makeFieldCtx("svc");
120
+ Inject(MY_TOKEN)(undefined, ctx);
121
+ class Consumer {}
122
+ const instance = new Consumer() as Record<string, unknown>;
123
+ run(instance);
124
+ expect(() => instance.svc).toThrow("MY_SERVICE");
125
+ });
122
126
  });
123
127
 
124
- // ── InjectContainer ───────────────────────────────────────────────────────────
128
+ // ── @InjectContainer ──────────────────────────────────────────────────────────
125
129
 
126
130
  describe("InjectContainer", () => {
127
- it("injects the global container instance", () => {
128
- const initializers: Array<(this: unknown) => void> = [];
129
- const ctx = {
130
- name: "container",
131
- kind: "field" as const,
132
- addInitializer(fn: (this: unknown) => void) {
133
- initializers.push(fn);
134
- },
135
- } as ClassFieldDecoratorContext;
136
-
131
+ it("injects the global container on an unscoped class", () => {
132
+ const { ctx, run } = makeFieldCtx("c");
137
133
  InjectContainer()(undefined, ctx);
138
134
  const instance: Record<string, unknown> = {};
139
- initializers.forEach((fn) => {
140
- fn.call(instance);
141
- });
142
-
143
- expect(instance.container).toBeInstanceOf(Container);
135
+ run(instance);
136
+ expect(instance.c).toBeInstanceOf(Container);
137
+ expect(instance.c).toBe(container);
144
138
  });
145
139
  });
146
140
 
147
- // ── useService ────────────────────────────────────────────────────────────────
141
+ // ── @Scope ────────────────────────────────────────────────────────────────────
142
+
143
+ describe("Scope", () => {
144
+ it("creates a child container per instance", () => {
145
+ @Scope()
146
+ class Widget {}
147
+ const { ctx, run } = makeFieldCtx("c");
148
+ InjectContainer()(undefined, ctx);
149
+
150
+ const a = new Widget() as Record<string, unknown>;
151
+ run(a);
152
+ const b = new Widget() as Record<string, unknown>;
153
+ run(b);
154
+
155
+ expect(a.c).toBeInstanceOf(Container);
156
+ expect(b.c).toBeInstanceOf(Container);
157
+ expect(a.c).not.toBe(b.c); // each instance gets its own scope
158
+ expect(a.c).not.toBe(container); // it's a child, not the root
159
+ });
160
+
161
+ it("services registered in configure are resolvable within the scope", () => {
162
+ const DB_URL = token<string>("DB_URL");
163
+
164
+ @Scope((c) => { c.registerValue(DB_URL, "postgres://localhost"); })
165
+ class DataModule {}
166
+
167
+ const { ctx, run } = makeFieldCtx("db");
168
+ Inject(DB_URL)(undefined, ctx);
169
+
170
+ const instance = new DataModule() as Record<string, unknown>;
171
+ run(instance);
148
172
 
149
- describe("useService", () => {
150
- it("resolves by Token", () => {
173
+ expect(instance.db).toBe("postgres://localhost");
174
+ });
175
+
176
+ it("@Inject resolves from the scoped container, not the global one", () => {
177
+ class ScopedRepo {}
178
+ // NOT registered in global container
151
179
  const VERSION = token<string>("VERSION");
152
- const c = createScope((sc) => sc.registerValue(VERSION, "1.0.0"));
153
- // useService uses global container — test via token on a fresh child
154
- expect(c.resolve(VERSION)).toBe("1.0.0");
180
+
181
+ @Scope((c) => {
182
+ c.register(ScopedRepo);
183
+ c.registerValue(VERSION, "2.0");
184
+ })
185
+ class App {}
186
+
187
+ const { ctx: repoCtx, run: runRepo } = makeFieldCtx("repo");
188
+ Inject(ScopedRepo)(undefined, repoCtx);
189
+ const { ctx: verCtx, run: runVer } = makeFieldCtx("version");
190
+ Inject(VERSION)(undefined, verCtx);
191
+
192
+ const instance = new App() as Record<string, unknown>;
193
+ runRepo(instance);
194
+ runVer(instance);
195
+
196
+ expect(instance.repo).toBeInstanceOf(ScopedRepo);
197
+ expect(instance.version).toBe("2.0");
155
198
  });
156
- });
157
199
 
158
- // ── createScope ───────────────────────────────────────────────────────────────
200
+ it("@InjectContainer returns the scoped child container", () => {
201
+ @Scope((c) => { c.registerValue(token<number>("X"), 42); })
202
+ class Service {}
159
203
 
160
- describe("createScope", () => {
161
- it("creates a child container without configure fn", () => {
162
- const child = createScope();
163
- expect(child).toBeInstanceOf(Container);
204
+ const { ctx, run } = makeFieldCtx("container");
205
+ InjectContainer()(undefined, ctx);
206
+
207
+ const instance = new Service() as Record<string, unknown>;
208
+ run(instance);
209
+
210
+ expect(instance.container).toBeInstanceOf(Container);
211
+ expect(instance.container).not.toBe(container);
164
212
  });
165
213
 
166
- it("applies the configure callback", () => {
167
- const DB = token<string>("DB");
168
- const child = createScope((c) =>
169
- c.registerValue(DB, "postgres://localhost"),
170
- );
171
- expect(child.resolve(DB)).toBe("postgres://localhost");
214
+ it("child container inherits registrations from the parent", () => {
215
+ class GlobalService {}
216
+ Injectable()(GlobalService, {} as ClassDecoratorContext);
217
+
218
+ @Scope()
219
+ class Child {}
220
+
221
+ const { ctx, run } = makeFieldCtx("svc");
222
+ Inject(GlobalService)(undefined, ctx);
223
+ const instance = new Child() as Record<string, unknown>;
224
+ run(instance);
225
+
226
+ expect(instance.svc).toBeInstanceOf(GlobalService);
172
227
  });
173
228
  });
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;
package/src/decorators.ts CHANGED
@@ -1,4 +1,15 @@
1
- import { container, type Container, Token, type Constructor, type InjectableOptions } from "./container";
1
+ import { createFieldDecorator, type FieldBinding } from "@praxisjs/decorators";
2
+
3
+ import { container, type Container, Token, type Constructor, type InjectableOptions } from "./container";
4
+
5
+ // Maps instances to their scoped child containers (set by @Scope).
6
+ const scopedContainers = new WeakMap<object, Container>();
7
+
8
+ function resolveFrom(instance: object) {
9
+ return scopedContainers.get(instance) ?? container;
10
+ }
11
+
12
+ // ── @Injectable ───────────────────────────────────────────────────────────────
2
13
 
3
14
  export function Injectable(options: InjectableOptions = {}) {
4
15
  return function (value: Constructor, _context: ClassDecoratorContext): void {
@@ -6,67 +17,95 @@ export function Injectable(options: InjectableOptions = {}) {
6
17
  };
7
18
  }
8
19
 
20
+ // ── @Scope ────────────────────────────────────────────────────────────────────
21
+
22
+ /**
23
+ * Creates a child DI container scoped to each instance of the decorated class.
24
+ * Services registered in the configure callback are available within the scope,
25
+ * and @Inject / @InjectContainer will resolve from it automatically.
26
+ *
27
+ * @Scope((c) => {
28
+ * c.register(UserRepository);
29
+ * c.registerValue(AuthToken, 'abc');
30
+ * })
31
+ * class UserModule extends StatefulComponent {
32
+ * @Inject(UserRepository) repo!: UserRepository;
33
+ * }
34
+ */
35
+ export function Scope(configure?: (c: Container) => void) {
36
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
+ return function(cls: new (...args: any[]) => any, _ctx: ClassDecoratorContext): any {
38
+
39
+ return class extends cls {
40
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
41
+ constructor(...args: any[]) {
42
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
43
+ super(...args);
44
+ const child = container.createChild();
45
+ configure?.(child);
46
+ scopedContainers.set(this as object, child);
47
+ }
48
+ };
49
+ };
50
+ }
51
+
52
+ // ── @Inject ───────────────────────────────────────────────────────────────────
53
+
9
54
  export function Inject<T>(dep: Constructor<T> | Token<T>) {
10
- return function (_value: undefined, context: ClassFieldDecoratorContext): void {
11
- const cache = new WeakMap<object, T>();
12
- const propertyKey = String(context.name);
55
+ const cache = new WeakMap<object, T>();
13
56
 
14
- context.addInitializer(function (this: unknown) {
15
- Object.defineProperty(this, context.name, {
16
- get(this: object): T {
17
- if (!cache.has(this)) {
18
- let resolved: T;
19
- try {
20
- resolved = container.resolve(dep as Constructor<T>);
21
- } catch (err) {
22
- throw new Error(
23
- `[Inject] Failed to resolve "${
24
- dep instanceof Token
25
- ? dep.toString()
26
- : (dep as Constructor).name
27
- }" in "${(this as { constructor: { name: string } }).constructor.name}.${propertyKey}": ${(err as Error).message}`,
57
+ return createFieldDecorator({
58
+ bind(_instance, name, _initialValue): FieldBinding {
59
+ return {
60
+ descriptor: {
61
+ get(this: object): T {
62
+ if (!cache.has(this)) {
63
+ const c = resolveFrom(this);
64
+ let resolved: T;
65
+ try {
66
+ resolved = c.resolve(dep as Constructor<T>);
67
+ } catch (err) {
68
+ throw new Error(
69
+ `[Inject] Failed to resolve "${
70
+ dep instanceof Token
71
+ ? dep.toString()
72
+ : (dep as Constructor).name
73
+ }" in "${(this as { constructor: { name: string } }).constructor.name}.${name}": ${(err as Error).message}`,
74
+ );
75
+ }
76
+ cache.set(this, resolved);
77
+ }
78
+ return cache.get(this) as T;
79
+ },
80
+ set(_value: unknown): void {
81
+ if (process.env.NODE_ENV !== "production") {
82
+ console.warn(
83
+ `[Inject] "${name}" is managed by the DI container and cannot be assigned directly.`,
28
84
  );
29
85
  }
30
- cache.set(this, resolved);
31
- }
32
- return cache.get(this) as T;
86
+ },
33
87
  },
34
-
35
- set(_value: unknown): void {
36
- if (process.env.NODE_ENV !== "production") {
37
- console.warn(
38
- `[Inject] "${propertyKey}" is managed by the DI container and cannot be assigned directly.`,
39
- );
40
- }
41
- },
42
-
43
- enumerable: true,
44
- configurable: true,
45
- });
46
- });
47
- };
88
+ };
89
+ },
90
+ // DI decorators work on any class, not just StatefulComponent
91
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
92
+ }) as unknown as (_value: undefined, context: ClassFieldDecoratorContext<any>) => void;
48
93
  }
49
94
 
95
+ // ── @InjectContainer ──────────────────────────────────────────────────────────
96
+
50
97
  export function InjectContainer() {
51
- return function (_value: undefined, context: ClassFieldDecoratorContext): void {
52
- context.addInitializer(function (this: unknown) {
53
- Object.defineProperty(this, context.name, {
54
- get() {
55
- return container;
98
+ return createFieldDecorator({
99
+ bind(_instance, _name, _initialValue): FieldBinding {
100
+ return {
101
+ descriptor: {
102
+ get(this: object) {
103
+ return resolveFrom(this);
104
+ },
56
105
  },
57
- enumerable: true,
58
- configurable: true,
59
- });
60
- });
61
- };
62
- }
63
-
64
- export function useService<T>(dep: Constructor<T> | Token<T>): T {
65
- return container.resolve(dep as Constructor<T>);
66
- }
67
-
68
- export function createScope(configure?: (c: Container) => void): Container {
69
- const child = container.createChild();
70
- configure?.(child);
71
- return child;
106
+ };
107
+ },
108
+ // DI decorators work on any class, not just StatefulComponent
109
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
110
+ }) as unknown as (_value: undefined, context: ClassFieldDecoratorContext<any>) => void;
72
111
  }
package/src/index.ts CHANGED
@@ -4,12 +4,11 @@ export {
4
4
  Injectable,
5
5
  Inject,
6
6
  InjectContainer,
7
- useService,
8
- createScope,
7
+ Scope,
9
8
  } from "./decorators";
10
9
  export { Container, container, Token, token } from "./container";
11
10
  export type {
12
- Scope,
11
+ Scope as ScopeType,
13
12
  InjectableOptions,
14
13
  Constructor,
15
14
  ServiceDescriptor,