@decohere/crucible 0.0.1-dev.98 → 0.0.1-dev.99

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.
package/README.md ADDED
@@ -0,0 +1,351 @@
1
+ # @decohere/crucible
2
+
3
+ Lightweight dependency injection for Decohere. Register classes (or tokens) on a `CrucibleBuilder`, `build()` a container, then `resolve()` graphs of constructor-injected services.
4
+
5
+ Higher layers (for example Cortex `Builder.withServices`) register on a `CrucibleBuilder`. You can also build and resolve from a container yourself.
6
+
7
+ ## When to use
8
+
9
+ | Use Crucible | Do not use Crucible |
10
+ |---|---|
11
+ | Constructor-injected services and controllers | Replacing Cortex / Protocol / Codex — those compose *on top of* Crucible |
12
+ | Tokens for interfaces, config objects, and “many of this kind” | Auto-scanning folders or Nest-style modules (not in this package) |
13
+ | Request / singleton / transient lifetimes and `using` disposal | Throwing on a missing dependency (unregistered keys resolve to `null`) |
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pnpm add @decohere/crucible
19
+ ```
20
+
21
+ ```ts
22
+ import { CrucibleBuilder, LifetimeScopeToken, Scopes } from "@decohere/crucible";
23
+ import { inject, injectable } from "@decohere/crucible/decorators";
24
+ ```
25
+
26
+ Decorator metadata is required. Enable it in `tsconfig.json` (the Decohere scaffold and this repo already do):
27
+
28
+ ```json
29
+ {
30
+ "compilerOptions": {
31
+ "experimentalDecorators": true,
32
+ "emitDecoratorMetadata": true
33
+ }
34
+ }
35
+ ```
36
+
37
+ `@decohere/nexus/reflection` loads `reflect-metadata`. Import Crucible (or Nexus) before constructing decorated classes.
38
+
39
+ ---
40
+
41
+ ## Constructor injection — `@inject` is usually unnecessary
42
+
43
+ If the constructor parameter is a **concrete class**, Crucible reads `design:paramtypes` and resolves by the class name. Mark the *consumer* `@injectable()` so TypeScript/SWC emit that metadata. Do **not** add `@inject` for that parameter.
44
+
45
+ ```ts
46
+ import { injectable } from "@decohere/crucible/decorators";
47
+ import { SampleService } from "./sample.service.js";
48
+
49
+ @injectable()
50
+ export class HelloController {
51
+ public constructor(private readonly sample: SampleService) {}
52
+ }
53
+ ```
54
+
55
+ ```ts
56
+ import { CrucibleBuilder } from "@decohere/crucible";
57
+
58
+ const builder = new CrucibleBuilder();
59
+ builder.registerSingleton(SampleService);
60
+ builder.registerRequestScoped(HelloController);
61
+ const container = builder.build();
62
+
63
+ using scope = container.beginScope("req");
64
+ const hello = scope.resolve(HelloController);
65
+ ```
66
+
67
+ `@injectable()` is an empty class decorator. Its job is to force `design:paramtypes` onto the constructor. Any other class decorator (`@Controller()`, `@Trace`, …) also does that; `@injectable()` is the one to use when there is nothing else.
68
+
69
+ ### Do not `import type` injected classes
70
+
71
+ `import type` is erased at compile time. `emitDecoratorMetadata` then records `Object` (or nothing) instead of the constructor. Crucible looks up `"Object"`, finds no registration, and **returns `null`**. The first method call on that dependency throws (`Cannot read properties of null`).
72
+
73
+ ```ts
74
+ // Wrong — SampleService does not exist at runtime, paramtypes collapse to Object
75
+ import type { SampleService } from "./sample.service.js";
76
+
77
+ // Right — value import keeps the constructor for reflect-metadata
78
+ import { SampleService } from "./sample.service.js";
79
+ ```
80
+
81
+ `import type` is correct for **interfaces** and type-only helpers. Those have no runtime constructor; pair them with `@inject(token)` (see below).
82
+
83
+ Linters that prefer type-only imports (`import type` / `import { type X }`) will flag a class that appears unused except as a constructor type. Keep the value import and suppress that rule locally — for example with oxlint (or Biome, or ESLint):
84
+
85
+ ```ts
86
+ // oxlint-disable-next-line typescript/consistent-type-imports -- constructor injection needs the class at runtime
87
+ import { Workspace } from "@decohere/mesh";
88
+ ```
89
+
90
+ That pattern is used throughout Forge (`Build`, `Clean`, …). Same idea applies to ESLint `consistent-type-imports` or equivalent.
91
+
92
+ ---
93
+
94
+ ## When you *do* need `@inject`
95
+
96
+ `@inject(key)` overrides the paramtypes lookup with an explicit key: a class constructor, string, or symbol. Use it when there is no useful constructor in `design:paramtypes`.
97
+
98
+ | Situation | Why `@inject` |
99
+ |---|---|
100
+ | Interface or type alias (`ISampleStore`) | No runtime class; paramtypes is `Object` |
101
+ | String / symbol token | Registration key is not the class name |
102
+ | Array of implementations (`Transformer[]`) | Paramtypes is `Array`; without a token, Crucible would `resolveMany("Array")` |
103
+ | Paramtypes collapsed to `Object` (circular import, decorate-before-init) | Explicit token still resolves |
104
+
105
+ ```ts
106
+ import { inject, injectable } from "@decohere/crucible/decorators";
107
+
108
+ export const SampleStoreToken = Symbol("SampleStore");
109
+
110
+ export interface ISampleStore {
111
+ get(id: string): Promise<unknown>;
112
+ }
113
+
114
+ @injectable()
115
+ export class SampleService {
116
+ public constructor(
117
+ @inject(SampleStoreToken) private readonly store: ISampleStore,
118
+ ) {}
119
+ }
120
+ ```
121
+
122
+ Keep tokens in a `const.ts` (or next to the interface). Register against the same token:
123
+
124
+ ```ts
125
+ builder.registerSingleton(SampleStoreToken, PostgresSampleStore);
126
+ // or an already-built instance:
127
+ builder.registerSingleton(SampleStoreToken, store);
128
+ ```
129
+
130
+ `@inject` takes `string | symbol | Constructable`. Prefer `@inject(SampleService)` over `@inject("SampleService")` — the class tracks renames. There is no `@inject({ many, optional })` yet — arrays use `resolveMany` automatically when the reflected type is `Array`, and missing keys are `null` (see [Optional / missing](#optional--missing-dependencies)).
131
+
132
+ ---
133
+
134
+ ## Registration and lifetimes
135
+
136
+ Keys are the class **name**, a string, or a symbol’s **description**. `resolve(SampleService)`, `resolve("SampleService")`, `@inject(SampleService)`, and `@inject("SampleService")` hit the same registration.
137
+
138
+ Registering the same class (or the same type on a token) twice **throws** `RegistrationError` — silent last-wins hides accidental double-registers. Replace it explicitly:
139
+
140
+ ```ts
141
+ builder.override.registerSingleton(SampleService);
142
+ builder.override.registerTransient(SampleService);
143
+ ```
144
+
145
+ `builder.override` is a registrar with replace already bound — the builder itself is not mutated. Two different types on one token is still multi-bind: `resolve` throws `ResolveError`, `resolveMany` returns both.
146
+
147
+ | Lifetime | Cached |
148
+ |---|---|
149
+ | Singleton | On the root container — shared with every nested scope |
150
+ | Request | On the current scope — new instance per `beginScope` |
151
+ | Transient | Never — new instance every `resolve` |
152
+
153
+ ```ts
154
+ builder.registerSingleton(SampleService);
155
+ builder.registerSingleton(Token, SampleService);
156
+ builder.registerSingleton(Token, existingInstance);
157
+
158
+ builder.registerRequestScoped(HelloController);
159
+ builder.registerRequestScoped(Token, HelloController);
160
+ builder.registerRequestScoped(Token, () => createConnection());
161
+
162
+ builder.registerTransient(Helper);
163
+ builder.registerTransient(Token, Helper);
164
+ ```
165
+
166
+ `registerType(type, scope?)` and `registerTypes(...)` are the same registrations without the lifetime in the method name (default **transient**). `Scopes.singleton` is the string `"single-instance"`.
167
+
168
+ Cortex HTTP: controllers are **request-scoped** (one per request). Services are usually **singletons**.
169
+
170
+ `registerSingleton(Class, instance)` binds an existing object under the class name. Terminal does this for the workspace:
171
+
172
+ ```ts
173
+ builder.registerSingleton(workspace.constructor, workspace);
174
+ ```
175
+
176
+ ---
177
+
178
+ ## Recipes
179
+
180
+ ### Many implementations of one token
181
+
182
+ Register several types under the same token. Inject them as an array with `@inject(Token)`.
183
+
184
+ ```ts
185
+ export const TransformerKey = Symbol("TransformerKey");
186
+
187
+ builder.registerSingleton(TransformerKey, SwcTranspiler);
188
+ builder.registerSingleton(TransformerKey, TypeEmitter);
189
+ ```
190
+
191
+ ```ts
192
+ @injectable()
193
+ export class Build {
194
+ public constructor(
195
+ @inject(TransformerKey) private readonly transformers: Transformer[],
196
+ ) {}
197
+ }
198
+ ```
199
+
200
+ `resolve(TransformerKey)` **throws** `ResolveError` when more than one registration exists. Use `resolveMany(TransformerKey)` or constructor injection of an array.
201
+
202
+ ### Request scopes and disposal
203
+
204
+ ```ts
205
+ builder.registerRequestScoped(UnitOfWork);
206
+ builder.registerSingleton(Clock);
207
+ const container = builder.build();
208
+
209
+ {
210
+ using scope = container.beginScope("req-1");
211
+ const uow = scope.resolve(UnitOfWork); // per-scope
212
+ const clock = scope.resolve(Clock); // same singleton as the root
213
+ } // scope[Symbol.dispose]() — request-scoped instances are disposed
214
+
215
+ await using scope = container.beginScope("req-2");
216
+ // async teardown via Symbol.asyncDispose if the instance implements it
217
+ ```
218
+
219
+ - Child scopes copy the parent **registry** when created; they do not share the request-instance cache.
220
+ - Nested `beginScope` is allowed; each level has its own request instances.
221
+ - Named scopes: a live duplicate name throws `DuplicateScopeError`. After dispose, the name can be reused. Unnamed scopes do not collide.
222
+ - Disposing a parent does **not** dispose child scopes. Dispose the scope you created.
223
+ - Singletons are cached on the root container and shared with nested scopes. Resolving a singleton after the root is disposed throws.
224
+
225
+ Cortex `App.dispose` (Flux HMR / SIGINT) disposes the **root** container, then Scribe. Implement `Symbol.asyncDispose` (or `Symbol.dispose`) on long-lived services — sockets, pools, timers. Only **constructed** instances are disposed (resolve them at boot; transients are not cached). `export default app` is the composition-root contract; do not wrap it for extra teardown.
226
+
227
+ The current scope is always registered as `LifetimeScopeToken`:
228
+
229
+ ```ts
230
+ import { type LifetimeScope, LifetimeScopeToken } from "@decohere/crucible";
231
+ import { inject, injectable } from "@decohere/crucible/decorators";
232
+
233
+ @injectable()
234
+ export class ExceptionFilterHost {
235
+ public constructor(
236
+ @inject(LifetimeScopeToken) private readonly scope: LifetimeScope,
237
+ ) {}
238
+ }
239
+ ```
240
+
241
+ ### Factories
242
+
243
+ ```ts
244
+ builder.registerRequestScoped("connection", () => createConnection());
245
+ const connection = container.resolve("connection");
246
+ ```
247
+
248
+ Arrow factories are called as functions (not `new`). Use them when construction needs arguments Crucible should not know about.
249
+
250
+ ### Optional / missing dependencies
251
+
252
+ Unregistered keys do **not** throw. `resolve` returns `null` and logs a warning. Optional constructor params (`dep?: Foo`) still go through resolution — you get `null` if nothing is registered.
253
+
254
+ ```ts
255
+ @injectable()
256
+ export class App {
257
+ public constructor(
258
+ @inject(HttpProviderToken) private readonly http?: HttpProvider,
259
+ ) {}
260
+ }
261
+ ```
262
+
263
+ If a concrete class parameter is `null` at runtime, the usual cause is metadata loss (`import type`, missing `@injectable()`, or paramtypes `Object`) — not an optional registration.
264
+
265
+ ### Cortex HTTP apps
266
+
267
+ ```ts
268
+ import { Builder } from "@decohere/cortex";
269
+ import { FastifyProvider } from "@decohere/cortex/providers";
270
+ import { SampleService } from "./services/sample.service.js";
271
+ import { HelloController } from "./controllers/hello.controller.js";
272
+
273
+ await Builder.create()
274
+ .withHttp(FastifyProvider)
275
+ .withControllers(HelloController)
276
+ .withServices((cb) => {
277
+ cb.registerSingleton(SampleService);
278
+ })
279
+ .buildAndStart();
280
+ ```
281
+
282
+ `withHttp` / `withControllers` already register the HTTP stack (router, controllers as request-scoped, …). Application services go in `withServices`.
283
+
284
+ ### Tests
285
+
286
+ Prefer constructing the subject with explicit fakes when you do not need the container. Use `CrucibleBuilder` when the test *is* the wiring (scopes, tokens, many-implementations).
287
+
288
+ ```ts
289
+ const builder = new CrucibleBuilder();
290
+ builder.registerSingleton(SampleService, fakeSample);
291
+ builder.registerRequestScoped(HelloController);
292
+ const container = builder.build();
293
+ using scope = container.beginScope("test");
294
+ const hello = scope.resolve(HelloController);
295
+ ```
296
+
297
+ To assert constructor arguments under DI, use `spyOnConstructor` from `@decohere/nexus/testing` — a bare `vi.spyOn` / `vi.fn` constructor drops `design:paramtypes`. See [Asserting DI constructor arguments](../../docs/testing/how-tos/vitest-constructor-spy-with-crucible-di.md).
298
+
299
+ ---
300
+
301
+ ## Public API
302
+
303
+ ### `@decohere/crucible`
304
+
305
+ | Export | Role |
306
+ |---|---|
307
+ | `CrucibleBuilder` | Fluent registration; `build()` → `Crucible` |
308
+ | `Crucible` | Container: `resolve`, `resolveMany`, `beginScope`, `Symbol.dispose` / `Symbol.asyncDispose` |
309
+ | `IContainer` / `LifetimeScope` / `ContainerBuilder` / `ServiceProvider` | Composition interfaces |
310
+ | `Scopes` | `{ request, singleton: "single-instance", transient }` |
311
+ | `LifetimeScopeToken` | Symbol for the current scope |
312
+ | `ResolveError` | `resolve` when multiple registrations share a key |
313
+ | `DuplicateScopeError` | `beginScope` with a live duplicate name |
314
+ | `RegistrationError` | Same type registered twice without `builder.override` |
315
+
316
+ ### `@decohere/crucible/decorators`
317
+
318
+ | Export | Role |
319
+ |---|---|
320
+ | `injectable()` | Empty class decorator; emit `design:paramtypes` |
321
+ | `inject(key)` | Parameter decorator; resolve by string or symbol instead of the reflected class name |
322
+
323
+ ---
324
+
325
+ ## Pitfalls
326
+
327
+ 1. **`import type` on an injected class** — paramtypes become `Object` → `resolve` returns `null`. Use a value import.
328
+ 2. **Missing `@injectable()` (or any decorator) on the consumer** — no paramtypes, constructor is called with zero arguments.
329
+ 3. **`resolve` of a multi-bound token** — throws; use `resolveMany` or inject `T[]` with `@inject(token)`.
330
+ 4. **Same type registered twice** — throws `RegistrationError`. Use `builder.override.registerSingleton(...)` (tests, plugin replacements).
331
+ 5. **Missing registration** — `null`, not an exception. Null-deref on first use is the symptom.
332
+ 6. **Symbol keys** — the lookup key is `symbol.description`. Keep descriptions unique and stable.
333
+ 7. **Renaming a class** without a token — the registration key is `Class.name`. Tokens isolate you from renames.
334
+
335
+ ---
336
+
337
+ ## Non-goals
338
+
339
+ - Folder scanning / “declare a module and everything is registered”
340
+ - Property or setter injection (constructors only)
341
+ - `@inject({ many, optional })` metadata (not implemented; arrays and `null` cover the current cases)
342
+ - Compile-time graph checking
343
+
344
+ ## Dependencies
345
+
346
+ | Package | Role |
347
+ |---|---|
348
+ | [`@decohere/nexus`](../nexus) | Reflection helpers for `design:paramtypes` (`getMeta`, `isConstructable`, …) |
349
+ | [`@decohere/scribe`](../scribe/README.md) | Logger facade |
350
+
351
+ Layering: [architecture](../../docs/architecture/).
@@ -139,8 +139,9 @@ class Crucible {
139
139
  if (this.isDisposed) {
140
140
  throw new Error("This container has been disposed");
141
141
  }
142
- const { key, type, isInstance } = registration;
143
- const cacheKey = isInstance ? key.value : type;
142
+ const { type, isInstance } = registration;
143
+ // One cache entry per registration: an instance registration is its own entry.
144
+ const cacheKey = type;
144
145
  const existingInstance = this.instances.get(cacheKey);
145
146
  if (existingInstance != null) {
146
147
  return existingInstance;
@@ -31,8 +31,8 @@ class ServiceRegistry {
31
31
  throw new _errors.RegistrationError(`A service is already registered for key ${registration.key}. Use builder.override.registerSingleton() (or the matching register* method) to replace it.`);
32
32
  }
33
33
  this.setRegistration(registration.key, [
34
- registration,
35
- ...existing
34
+ ...existing,
35
+ registration
36
36
  ]);
37
37
  return registration;
38
38
  }
@@ -129,8 +129,9 @@ export class Crucible {
129
129
  if (this.isDisposed) {
130
130
  throw new Error("This container has been disposed");
131
131
  }
132
- const { key, type, isInstance } = registration;
133
- const cacheKey = isInstance ? key.value : type;
132
+ const { type, isInstance } = registration;
133
+ // One cache entry per registration: an instance registration is its own entry.
134
+ const cacheKey = type;
134
135
  const existingInstance = this.instances.get(cacheKey);
135
136
  if (existingInstance != null) {
136
137
  return existingInstance;
@@ -21,8 +21,8 @@ export class ServiceRegistry {
21
21
  throw new RegistrationError(`A service is already registered for key ${registration.key}. Use builder.override.registerSingleton() (or the matching register* method) to replace it.`);
22
22
  }
23
23
  this.setRegistration(registration.key, [
24
- registration,
25
- ...existing
24
+ ...existing,
25
+ registration
26
26
  ]);
27
27
  return registration;
28
28
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decohere/crucible",
3
- "version": "0.0.1-dev.98",
3
+ "version": "0.0.1-dev.99",
4
4
  "description": "Lightweight dependency injection container with decorator-based wiring",
5
5
  "keywords": [
6
6
  "typescript",
@@ -30,8 +30,8 @@
30
30
  "test": "echo \"Error: no test specified\" && exit 1"
31
31
  },
32
32
  "dependencies": {
33
- "@decohere/nexus": "0.0.1-dev.98",
34
- "@decohere/scribe": "0.0.1-dev.98"
33
+ "@decohere/nexus": "0.0.1-dev.99",
34
+ "@decohere/scribe": "0.0.1-dev.99"
35
35
  },
36
36
  "devDependencies": {}
37
37
  }