ripple-di 1.0.0 → 1.0.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.
Files changed (3) hide show
  1. package/README.md +240 -121
  2. package/dist/index.mjs +39 -4
  3. package/package.json +7 -4
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Ripple DI
2
2
 
3
- Scoped dependency injection for TypeScript without container lookups in application code.
3
+ Scoped dependency injection for TypeScript with automatic dependency tracking, lifecycle-aware cleanup, and no container lookups in application code.
4
4
 
5
5
  ## Example
6
6
 
@@ -29,7 +29,7 @@ Now replace only the configuration for one operation:
29
29
  import { provide, withOverrides } from "ripple-di"
30
30
 
31
31
  const users = await withOverrides(
32
- [provide(useConfig, { databaseUrl: testDatabaseUrl })],
32
+ provide(useConfig, { databaseUrl: testDatabaseUrl }),
33
33
  () => loadUsers(),
34
34
  )
35
35
  ```
@@ -37,7 +37,13 @@ const users = await withOverrides(
37
37
  `useDb` reads `useConfig()`, so Ripple DI builds a separate database for the test configuration and loads the users through it.
38
38
  The production database and everything unrelated keep the instances they already had, and the temporary database is closed when the callback finishes.
39
39
 
40
- This is the ripple: override one input, and only the values built from it change.
40
+ This is the ripple: override one input, and only the values derived from it are rebuilt.
41
+
42
+ ## Design trade-off
43
+
44
+ Ripple DI is an ambient service locator by design: code imports a dependency and calls it instead of receiving it as an argument.
45
+
46
+ This makes dependencies convenient to use and override, but also makes them less explicit: looking at a function's signature does not tell you which Ripple DI dependencies it reads.
41
47
 
42
48
  ## Install
43
49
 
@@ -48,59 +54,22 @@ npm install ripple-di
48
54
  Ripple DI needs `node:async_hooks`, so it runs on Node.js 18 or newer, Bun, and Deno with Node compatibility, but not in browsers.
49
55
  The package is published as ESM.
50
56
 
51
- ## Quick start
52
-
53
- Define each shared input and service once, next to the code that owns it.
54
- Factories are lazy: they run the first time something reads the value.
55
-
56
- ```ts
57
- // config.ts
58
- import { defineDependency } from "ripple-di"
59
-
60
- export const useConfig = defineDependency(() => ({
61
- databaseUrl: process.env.DATABASE_URL,
62
- }))
63
- ```
64
-
65
- ```ts
66
- // db.ts
67
- import { defineDependency } from "ripple-di"
68
-
69
- import { useConfig } from "./config"
70
-
71
- export const useDb = defineDependency(
72
- () => createDb(useConfig().databaseUrl),
73
- { dispose: db => db.close() },
74
- )
75
- ```
76
-
77
- Anything that needs the database imports it and calls it.
78
-
79
- ```ts
80
- import { useDb } from "./db"
81
-
82
- export async function loadUsers() {
83
- return useDb().query("select * from users")
84
- }
85
- ```
86
-
87
- The return type of `useDb()` is inferred from `createDb`.
88
-
89
57
  ## Which function do I call?
90
58
 
91
- | You want to | Call
92
- | --------------------------------------------------------- | ---------------------------
93
- | Define an input, derived value, or service | `defineDependency`
94
- | Supply the real values when the application starts | `install`
95
- | Replace values for one callback | `withOverrides`
96
- | Name an override of one dependency you write often | `createValueOverride`
97
- | Replace the same values for many separate calls | `createOverrideRunner`
98
- | Replace values for a lifetime you manage yourself | `createScope`
99
- | Supply a value, or a factory, to `install` or an override | `provide`, `provideFactory`
100
- | Shut everything down | `dispose`
59
+ | You want to | Call
60
+ | --------------------------------------------- | --------------------------------------------------
61
+ | Define an input, derived value, or service | `defineDependency`
62
+ | Supply the application's values at startup | `install` with `provide` or `provideFactory`
63
+ | Replace values for one callback | `withOverrides` with `provide` or `provideFactory`
64
+ | Keep one scope open across several operations | `createScope`
65
+ | Shut everything down | `dispose`
66
+
67
+ `createValueOverride` and `createOverrideRunner` in [Advanced usage](#advanced-usage) turn overrides you write repeatedly into reusable helpers.
101
68
 
102
69
  ## Define dependencies
103
70
 
71
+ Define each shared input and service once, in the module that owns it, and import it wherever it is used.
72
+
104
73
  Call `defineDependency<T>()` without a factory when a value must come from the application, request, or task boundary.
105
74
 
106
75
  ```ts
@@ -109,7 +78,7 @@ import { defineDependency, provide, withOverrides } from "ripple-di"
109
78
  const useTenant = defineDependency<Tenant>()
110
79
 
111
80
  await withOverrides(
112
- [provide(useTenant, tenant)],
81
+ provide(useTenant, tenant),
113
82
  () => processRequest(),
114
83
  )
115
84
  ```
@@ -117,29 +86,57 @@ await withOverrides(
117
86
  Reading it without a provider throws `MissingProviderError`.
118
87
  Pass a factory when the dependency has a built-in value.
119
88
  The factory runs lazily, and calls to other dependencies inside it are tracked.
120
- A function is always taken as that factory, so a dependency whose own value is a function needs `defineDependency(() => handler)`.
89
+ A function in the first argument is always the factory, so a dependency whose value is itself a function wraps it: `defineDependency(() => handler)`.
121
90
 
122
91
  ```ts
123
92
  const useClock = defineDependency(() => systemClock)
124
93
  const usePublicUrl = defineDependency(() => createPublicUrl(useConfig()))
125
94
  ```
126
95
 
127
- The result is cached and recreated wherever one of the dependencies it read is overridden.
128
- Add a `dispose` callback when values owned by Ripple DI need cleanup, as shown by `useDb` in the quick start.
96
+ The result is cached, and a scope that overrides one of the dependencies it read gets a separate result.
97
+ Its type comes from the factory, so `useDb()` returns whatever `createDb` returns.
98
+ Add a `dispose` callback when values owned by Ripple DI need cleanup, as shown by `useDb` in the example above.
99
+
100
+ A factory can read dependencies, but cannot create, enter, close, or retire scopes and installations in its own runtime; misuse throws `FactoryScopeOperationError`.
101
+
102
+ ### Promises and thenables
103
+
104
+ A factory returns the value itself, synchronously; disposers may be asynchronous.
105
+
106
+ ```ts
107
+ import { asValue, defineDependency } from "ripple-di"
108
+
109
+ // The usual case: the factory returns the value.
110
+ defineDependency(() => createSessionStore())
111
+
112
+ // Reading this throws AsyncFactoryError: reads made after an await are not tracked.
113
+ defineDependency(async () => loadSession())
114
+
115
+ // The promise itself is the cached dependency value.
116
+ defineDependency(() => asValue(loadSession()))
117
+
118
+ // A query builder implements `then` but is an ordinary value.
119
+ defineDependency(() => selectSessions())
120
+ ```
121
+
122
+ A factory is rejected only when it returns a native `Promise` object, so a value that merely implements `then` is stored as it is.
123
+ Wrap the result in `asValue` when the promise itself is the value the dependency holds.
124
+
125
+ `asValue` marks the promise as the value and does nothing else.
126
+ The dependencies its asynchronous work reads after an `await` are still untracked, so a promise cached for the whole application can end up built from the temporary values of whichever scope started it.
129
127
 
130
128
  ## Override dependencies
131
129
 
132
130
  `withOverrides` runs a callback with temporary values and cleans up whatever it created for that callback.
131
+ Pass one `provide` or `provideFactory` directly.
133
132
 
134
133
  ```ts
135
134
  import { provide, withOverrides } from "ripple-di"
136
135
 
137
136
  const users = await withOverrides(
138
- [
139
- provide(useConfig, {
140
- databaseUrl: "postgres://localhost/test",
141
- }),
142
- ],
137
+ provide(useConfig, {
138
+ databaseUrl: "postgres://localhost/test",
139
+ }),
143
140
  () => loadUsers(),
144
141
  )
145
142
  ```
@@ -159,43 +156,81 @@ await withOverrides(
159
156
  )
160
157
  ```
161
158
 
162
- For one dependency, you can omit the array:
163
-
164
- ```ts
165
- await withOverrides(
166
- provide(useConfig, testConfig),
167
- () => loadUsers(),
168
- )
169
- ```
170
-
171
159
  Overrides survive `await` and stay isolated between parallel callbacks.
172
160
 
173
161
  ```ts
174
162
  const [leftUsers, rightUsers] = await Promise.all([
175
- withOverrides([provide(useConfig, leftConfig)], () => loadUsers()),
176
- withOverrides([provide(useConfig, rightConfig)], () => loadUsers()),
163
+ withOverrides(provide(useConfig, leftConfig), () => loadUsers()),
164
+ withOverrides(provide(useConfig, rightConfig), () => loadUsers()),
177
165
  ])
178
166
  ```
179
167
 
180
- ### Name an override you write repeatedly
168
+ ### Awaitable callback results
169
+
170
+ `withOverrides`, and every helper built on it, awaits what its callback returns before closing the temporary scope, so returning a query builder from one of them runs the query.
171
+ Use such a value inside the callback, and use `createScope` when it has to outlive the callback.
172
+ Wrapping the value in an object prevents it from being awaited, but the temporary scope still closes before the caller receives it.
173
+ Anything that scope owned has already been cleaned up.
174
+
175
+ ## Where a value belongs
181
176
 
182
- When the same dependency is supplied the same way all over the application, `createValueOverride` turns that into one named helper.
177
+ The same tracked dependency calls that decide when a factory result must be rebuilt also decide which lifecycle owns it and invokes its disposer.
178
+ A factory-created value belongs to the innermost scope that supplied either its factory or one of the dependencies it read.
179
+ When its factory and everything it read belong to the application, the value belongs to the application too, and closing a child scope leaves it alone.
180
+
181
+ **Reading a dependency inside a scope does not make its value scope-local.**
183
182
 
184
183
  ```ts
185
- import { createValueOverride } from "ripple-di"
184
+ // Wrong: every request shares this one transaction.
185
+ const useTransaction = defineDependency(
186
+ () => useDb().transaction(),
187
+ { dispose: tx => tx.rollback() },
188
+ )
189
+ ```
186
190
 
187
- const withOpenAiClient = createValueOverride(useOpenAiClient)
191
+ `useDb` belongs to the application, so a transaction built from it belongs to the application too.
192
+ Every request reads the same transaction, and it is rolled back at shutdown rather than when the request ends.
188
193
 
189
- await withOpenAiClient(client, () => createTextEmbedding(text))
194
+ For a value that belongs to one operation, define the dependency without a built-in factory.
195
+ Provide its factory when you create the scope of that operation:
196
+
197
+ ```ts
198
+ const useTransaction = defineDependency<Transaction>({
199
+ dispose: tx => tx.rollback(),
200
+ })
201
+
202
+ await withOverrides(
203
+ provideFactory(useTransaction, () => useDb().transaction()),
204
+ () => processRequest(),
205
+ )
190
206
  ```
191
207
 
192
- Each call supplies the value it is given to one callback, exactly like writing `withOverrides` with a single `provide` by hand.
193
- Pass the ownership options once, and every call cleans its own value up.
208
+ Each call now builds its own transaction, still lazily, and the scope rolls it back when the callback finishes.
209
+ Reading `useTransaction()` outside such a scope throws `MissingProviderError` instead of quietly sharing one.
210
+
211
+ A value also becomes scope-local through what it reads: a factory that reads a dependency the scope provides — a request context, a tenant, a fixed clock — is rebuilt in that scope and cleaned up with it.
212
+
213
+ An installation follows the same rule: a value belongs to it when the installation supplied its factory or one of the dependencies the factory read.
194
214
 
195
215
  ```ts
196
- const withConnection = createValueOverride(useConnection, { dispose: true })
216
+ const useBuiltInConfig = defineDependency(() => rootConfig)
217
+ const useRootPool = defineDependency(() => createPool(useBuiltInConfig()), {
218
+ dispose: pool => pool.end(),
219
+ })
220
+
221
+ const useInstalledConfig = defineDependency<DatabaseConfig>()
222
+ const useInstalledPool = defineDependency(() => createPool(useInstalledConfig()), {
223
+ dispose: pool => pool.end(),
224
+ })
225
+
226
+ const installation = install(provide(useInstalledConfig, installedConfig))
227
+ useRootPool()
228
+ useInstalledPool()
229
+ await installation.close() // Before installing replacement providers.
197
230
  ```
198
231
 
232
+ The installed pool closes with the installation, while the root pool remains cached until `dispose()` because its factory read only built-in dependencies.
233
+
199
234
  ## Wire the application at startup
200
235
 
201
236
  Some dependencies get their real value only when the application starts.
@@ -211,11 +246,11 @@ export const useTenantResolver = defineDependency<TenantResolver>()
211
246
 
212
247
  ```ts
213
248
  // startup.ts
214
- import { install, provide } from "ripple-di"
249
+ import { dispose, install, provide } from "ripple-di"
215
250
 
216
251
  import { useTenantResolver, useWebConfig } from "./dependencies"
217
252
 
218
- const installation = install([
253
+ install([
219
254
  provide(useWebConfig, config),
220
255
  provide(useTenantResolver, resolveTenant),
221
256
  ])
@@ -223,20 +258,25 @@ const installation = install([
223
258
  try {
224
259
  await runApplication()
225
260
  } finally {
226
- await installation.close()
261
+ await dispose()
227
262
  }
228
263
  ```
229
264
 
230
265
  Installed providers are the fallback everywhere: request handlers, background jobs, tests, and any other code running outside a scope.
231
266
  Nothing is resolved eagerly, and a scoped override still wins over an installed provider.
232
- Closing the installation removes its providers and cleans up the scopes and owned values created beneath it.
267
+ Closing an installation removes its providers and cleans up values whose factory or tracked dependencies belong to it.
268
+ It keeps the runtime usable, so application-owned values remain cached for later installations.
269
+ Use `installation.close()` before a controlled replacement; use `dispose()` to shut the application down.
233
270
 
234
271
  - Installing while another installation or any scope is still open throws `InstallationConflictError`, whose message says what is still open.
235
272
  Await `installation.close()` before installing a replacement.
236
273
  - Providing the same dependency twice in one installation is rejected, exactly like in `withOverrides`.
237
- - Installing late is allowed: it applies to the reads that come after it, and closing it brings the earlier values back.
274
+ - Install once during startup.
275
+ Late installation is supported for a controlled bootstrap or a test setup: it applies to the reads that come after it, and closing it brings the earlier values back.
276
+ - If a resource needs the lifetime of each installation, define its dependency without a built-in factory and export a module provision builder that returns `provideFactory`.
277
+ This keeps the factory body in its owning module, but removes the built-in fallback: reading the dependency without that provision throws `MissingProviderError`.
238
278
  - Every worker thread and every process wires its own installation.
239
- - In tests, install once for the whole process and use `withOverrides` per test.
279
+ - In tests, install once for the whole process and override per test, as shown in [Test your application](#test-your-application).
240
280
 
241
281
  ### Collect the providers of several modules
242
282
 
@@ -264,17 +304,19 @@ export function getPlatformProvisions() {
264
304
 
265
305
  ```ts
266
306
  // startup.ts
267
- const installation = install([
307
+ import { dispose, install } from "ripple-di"
308
+
309
+ install([
268
310
  ...getCoreProvisions(),
269
311
  ...getPlatformProvisions(),
270
312
  ])
271
313
 
272
- onShutdown(() => installation.close())
314
+ onShutdown(() => dispose())
273
315
  ```
274
316
 
275
317
  - Export a function that builds the provisions rather than a ready-made array, so each installation gets provisions of its own.
276
318
  A provision that hands over ownership belongs to a single installation and cannot be reused by the next one.
277
- - The composition root that installs the provisions is also the one that closes the installation, from whichever shutdown hook the application already has.
319
+ - The composition root that installs the provisions also calls `dispose()` from the shutdown hook the application already has.
278
320
  - Tests have the same single composition root: one preload installs the provisions of every layer the suite needs.
279
321
  Separate preloads installing on their own would fail on the second `install` instead of adding their providers.
280
322
 
@@ -282,6 +324,7 @@ onShutdown(() => installation.close())
282
324
 
283
325
  Call `dispose()` when the application shuts down.
284
326
  It closes every scope and cleans up every owned value still held by the module-level API, including an active installation.
327
+ Closing only the installation can leave [application-owned values](#where-a-value-belongs) cached.
285
328
 
286
329
  ```ts
287
330
  import { dispose } from "ripple-di"
@@ -294,6 +337,66 @@ Values created inside `withOverrides` never wait for shutdown; they are cleaned
294
337
  `dispose()` is final: afterwards the runtime cannot resolve dependencies, create scopes, or install providers, and those calls throw `ScopeClosedError`.
295
338
  Do not use it to reset state between tests — use `withOverrides` for that, or create a separate runtime for each lifecycle.
296
339
 
340
+ ## Test your application
341
+
342
+ Install the wiring the suite needs once for the whole test process, from the preload or setup file the test runner already has, and give each test the values of its own.
343
+
344
+ ```ts
345
+ import {
346
+ createOverrideRunner,
347
+ createScope,
348
+ provide,
349
+ type Scope,
350
+ withOverrides,
351
+ } from "ripple-di"
352
+
353
+ test("loads users from the test database", () =>
354
+ withOverrides(
355
+ provide(useConfig, testConfig),
356
+ () => loadUsers(),
357
+ ),
358
+ )
359
+ ```
360
+
361
+ The scope lives exactly as long as that callback, so tests running in parallel cannot see one another's overrides.
362
+
363
+ When several tests share one set of values that is expensive to build, create the scope once and run each test inside it.
364
+
365
+ ```ts
366
+ let scope: Scope
367
+
368
+ beforeAll(() => {
369
+ scope = createScope(provide(useConfig, testConfig))
370
+ })
371
+
372
+ afterAll(() => scope.close())
373
+
374
+ test("lists users", () => scope.run(() => listUsers()))
375
+ test("creates a user", () => scope.run(() => createUser()))
376
+ ```
377
+
378
+ Entering a scope in a hook does not carry it into the tests that follow.
379
+
380
+ ```ts
381
+ // Wrong: the scope is current inside this callback and nowhere else.
382
+ beforeAll(() => scope.run(() => {}))
383
+ ```
384
+
385
+ - `scope.run` and `withOverrides` make a scope current for their own callback only, so every test that needs the scope runs inside one of them.
386
+ - Tests that share a suite scope also share the values cached in it, so keep that recipe for values they can safely reuse.
387
+ - `scope.run` does not report child scopes a test leaves open, so a test that creates one closes it itself instead of leaving it until `afterAll`.
388
+
389
+ When every test in a file needs the same overrides in a separate scope, a runner applies them to each test independently.
390
+
391
+ ```ts
392
+ const testConfigOverrides = createOverrideRunner(() =>
393
+ provide(useConfig, testConfig),
394
+ )
395
+
396
+ test("lists users", testConfigOverrides.wrap(() => listUsers()))
397
+ test("creates a user", testConfigOverrides.wrap(() => createUser()))
398
+ ```
399
+
297
400
  ## Advanced usage
298
401
 
299
402
  Everything below is optional.
@@ -320,6 +423,7 @@ provide(useDb, fakeDb, { dispose: db => db.closeImmediately() })
320
423
 
321
424
  A function passed to `provide` stays an ordinary function value; use `provideFactory` when it should build the value instead.
322
425
  The dependencies an override factory reads are tracked like the ones read by the factory it replaces.
426
+ An override factory cannot read the previous value of the dependency it replaces; define a base dependency and a decorated one instead.
323
427
  `dispose: true` reuses the disposer from `defineDependency`, so it throws right away when the dependency declares none.
324
428
 
325
429
  A provision that hands over ownership belongs to a single scope or installation.
@@ -332,15 +436,13 @@ const useQueue = defineDependency<Queue>({
332
436
  dispose: queue => queue.close(),
333
437
  })
334
438
 
335
- const installation = install([
336
- provideFactory(useQueue, () => createQueue(queueUrl)),
337
- ])
439
+ install(provideFactory(useQueue, () => createQueue(queueUrl)))
338
440
 
339
- // Later, at shutdown:
340
- await installation.close()
441
+ // Later, at application shutdown:
442
+ await dispose()
341
443
  ```
342
444
 
343
- The installed factory is lazy, and its queue client is closed with the installation.
445
+ The installed factory is lazy, and its queue client is closed either with the installation during a controlled replacement or with the runtime at application shutdown.
344
446
 
345
447
  ### Manage a scope explicitly
346
448
 
@@ -350,9 +452,7 @@ Use `createScope` when several operations share the same overrides and close at
350
452
  ```ts
351
453
  import { createScope, provide } from "ripple-di"
352
454
 
353
- const scope = createScope([
354
- provide(useConfig, tenantConfig),
355
- ])
455
+ const scope = createScope(provide(useConfig, tenantConfig))
356
456
 
357
457
  try {
358
458
  await scope.run(() => processTenant())
@@ -361,13 +461,36 @@ try {
361
461
  }
362
462
  ```
363
463
 
364
- - `scope.run` makes the scope current for a callback without closing it.
464
+ - `scope.run` makes the scope current for a callback without closing it, and returns the callback's result unchanged.
365
465
  - `scope.resolve(useDb)` reads a dependency from that scope rather than the current one.
466
+ A factory reads from its own scope only, so calling `scope.resolve` on a different scope inside one throws `CrossScopeResolutionError`.
366
467
  - `scope.createScope` and `scope.withOverrides` create children of that scope instead of the current one.
367
468
  - A child of the scope that `withOverrides` created must be closed before the callback returns, otherwise Ripple DI closes it and throws `LeakedChildScopeError`.
368
469
  - `scope.close()` closes the scope and everything below it, while `scope.retire()` waits for child scopes to finish first.
369
470
  - Closing disposes what the scope itself created; reused application values stay open.
370
471
  - Cleanup continues past a failing disposer and reports every failure in one `AggregateError`.
472
+ - A disposer, and any async work it starts, cannot read dependencies or manage scopes and installations in the runtime being closed; misuse throws `DisposerContextError`.
473
+ Put everything cleanup needs into the dependency value itself.
474
+
475
+ ### Name an override you write repeatedly
476
+
477
+ When a dependency is replaced in only one place, keep `withOverrides` with one `provide` at the call site.
478
+ For repeated replacements, `createValueOverride` turns the pattern into a named helper.
479
+
480
+ ```ts
481
+ import { createValueOverride } from "ripple-di"
482
+
483
+ const withOpenAiClient = createValueOverride(useOpenAiClient)
484
+
485
+ await withOpenAiClient(client, () => createTextEmbedding(text))
486
+ ```
487
+
488
+ Each call supplies the value it is given to one callback, exactly like writing `withOverrides` with a single `provide` by hand.
489
+ Pass the ownership options once, and every call cleans its own value up.
490
+
491
+ ```ts
492
+ const withConnection = createValueOverride(useConnection, { dispose: true })
493
+ ```
371
494
 
372
495
  ### Reuse one set of overrides
373
496
 
@@ -377,9 +500,9 @@ A long-lived object such as an API caller or a job worker is created once, but e
377
500
  ```ts
378
501
  import { createOverrideRunner, provide } from "ripple-di"
379
502
 
380
- const jobOverrides = createOverrideRunner(() => [
503
+ const jobOverrides = createOverrideRunner(() =>
381
504
  provide(useJobContext, createJobContext(), { dispose: true }),
382
- ])
505
+ )
383
506
 
384
507
  const worker = createWorker(job => jobOverrides.run(() => handleJob(job)))
385
508
  ```
@@ -403,9 +526,9 @@ That also suits a file of independent test cases that share one set of overrides
403
526
  `extend` returns a runner with one more layer of overrides and leaves the runner it extends unchanged:
404
527
 
405
528
  ```ts
406
- const tenantOverrides = jobOverrides.extend(() => [
529
+ const tenantOverrides = jobOverrides.extend(() =>
407
530
  provide(useTenantResolver, tenantResolver),
408
- ])
531
+ )
409
532
 
410
533
  await tenantOverrides.run(() => handleRequest())
411
534
  ```
@@ -449,6 +572,7 @@ await Promise.all([
449
572
  ```
450
573
 
451
574
  A dependency belongs to the runtime that defined it and cannot be read or overridden in another one.
575
+ The module-level `defineDependency` always defines a dependency of the built-in runtime, so an application factory like the one above defines every dependency it needs through its own runtime.
452
576
  Pass `name` to `createRuntime` to see that name in error messages.
453
577
  Every runtime has the same methods, and each has a module-level counterpart that targets the built-in runtime:
454
578
 
@@ -471,36 +595,31 @@ Errors thrown by your own factory arrive wrapped in `FactoryError` with the orig
471
595
  A failed factory is not cached, so the next read tries again.
472
596
 
473
597
  These errors name the dependencies involved.
474
- Pass `name` to give one a readable label instead of a generated one:
598
+ An explicit `name` is always used when you provide one:
475
599
 
476
600
  ```ts
477
601
  const useConfig = defineDependency(loadConfig, { name: "config" })
478
602
  ```
479
603
 
480
604
  The name only affects messages.
605
+ Without an explicit name, Ripple DI uses a non-empty factory name or shows a generated name with the `defineDependency` call location, such as `dependency#21 (packages/core/src/openai/config.ts:36)`.
606
+ The location follows the runtime's stack and source maps, is captured once when the dependency is defined, and adds no work during resolution.
481
607
 
482
- ## Limits
608
+ ## Do not mix package copies
609
+
610
+ Two copies of Ripple DI, separately installed or bundled, each run their own graph and cannot be combined.
611
+ Do not pass dependencies or provisions between them, and do not call one copy's dependency inside a factory owned by the other.
612
+ Such a call is not detected: the dependency may resolve against its own copy instead of failing at the boundary.
613
+
614
+ Declare `ripple-di` as a peer dependency in any package that exports dependencies of its own.
615
+
616
+ ## Caveats
483
617
 
484
- - Factories are synchronous; disposers may be asynchronous.
485
- A factory returns the value itself, so a value that implements `then`, such as a query builder or another awaitable client, is stored as it is.
486
- A factory is rejected only when it returns a native `Promise` object, because dependency reads made after an `await` are not tracked.
487
- When the promise is the value, wrap the result in `asValue`: `defineDependency(() => asValue(loadToken()))` defines a `Promise<Token>` dependency created once and awaited by its readers.
488
- - A `withOverrides` callback that returns an awaitable value has it awaited, exactly like any promise returned from a callback, so returning a query builder runs its query.
489
- Use the value inside the callback, and use `createScope` when it has to outlive the callback.
490
- Returning it wrapped in an object avoids the await but hands back a value whose scope is already closed, together with everything that scope owned.
491
- - A factory can read dependencies, but cannot create, enter, close, or retire scopes and installations in its own runtime; misuse throws `FactoryScopeOperationError`.
492
- - A disposer, and any async work it starts, cannot read dependencies or manage scopes and installations in the runtime being closed; misuse throws `DisposerContextError`.
493
- Put everything cleanup needs into the dependency value itself.
494
- - A factory reads from its own scope only; `scope.resolve` on a different scope throws `CrossScopeResolutionError`.
495
618
  - Only the dependency calls made while a factory runs are tracked.
496
- Reads from `process.env`, `Date.now()`, or another async context are not.
497
- - An override factory cannot read the previous value of the dependency it replaces; define a base dependency and a decorated one instead.
498
- - A factory-created value is scoped only by the dependencies its factory reads.
499
- A value whose factory reads none stays shared even when it is first requested inside a scope.
619
+ Everything else stays invisible: `process.env`, `Date.now()`, and any dependency called after the factory has returned.
500
620
  - Read dependencies where you use them.
501
621
  A module-level `const db = useDb()` freezes one scope's value forever.
502
- - Two copies of Ripple DI, separately installed or bundled, each run their own graph and cannot be combined.
503
- Do not pass dependencies or provisions between them, and do not call one copy's dependency inside a factory owned by the other.
622
+ - When a factory catches an error from reading a dependency, its value is not shared: every scope that asks for it builds a new one.
504
623
 
505
624
  ## License
506
625
 
package/dist/index.mjs CHANGED
@@ -170,13 +170,45 @@ var DependencyNodeImpl = class {
170
170
  dispose;
171
171
  dependency;
172
172
  constructor(definition) {
173
- this.name = definition.name ?? `dependency#${this.id}`;
173
+ const generatedName = `dependency#${this.id}`;
174
+ this.name = definition.name ?? (definition.definitionSite ? `${generatedName} (${definition.definitionSite})` : generatedName);
174
175
  this.runtime = definition.runtime;
175
176
  this.defaultFactory = definition.defaultFactory;
176
177
  this.dispose = definition.dispose;
177
178
  this.dependency = (() => this.runtime.readCallable(this));
178
179
  }
179
180
  };
181
+ /** Captures and reduces a definition stack before it enters private metadata. */
182
+ function captureDefinitionSite(caller) {
183
+ const error = /* @__PURE__ */ new Error();
184
+ const captureStackTrace = typeof Error.captureStackTrace === "function" ? Error.captureStackTrace : void 0;
185
+ if (captureStackTrace) captureStackTrace(error, caller);
186
+ const frames = error.stack?.split("\n").slice(1) ?? [];
187
+ const skippedFrames = captureStackTrace ? frames : frames.slice(2);
188
+ for (const frame of skippedFrames) {
189
+ const site = definitionSiteFromFrame(frame);
190
+ if (site) return site;
191
+ }
192
+ }
193
+ function definitionSiteFromFrame(frame) {
194
+ let location = frame.trim().replace(/^at\s+/, "");
195
+ const openingParenthesis = location.lastIndexOf("(");
196
+ if (openingParenthesis >= 0 && location.endsWith(")")) location = location.slice(openingParenthesis + 1, -1);
197
+ else {
198
+ const atSign = location.lastIndexOf("@");
199
+ if (atSign >= 0) location = location.slice(atSign + 1);
200
+ location = location.replace(/^async\s+/, "");
201
+ }
202
+ const match = /^(.*):(\d+):\d+$/.exec(location);
203
+ if (!match || !match[1]) return;
204
+ let file = match[1].replace(/^file:\/\//, "").replaceAll("\\", "/");
205
+ try {
206
+ file = decodeURI(file);
207
+ } catch {}
208
+ const workingDirectory = `${process.cwd().replaceAll("\\", "/")}/`;
209
+ if (file.startsWith(workingDirectory)) file = file.slice(workingDirectory.length);
210
+ return `${file}:${match[2]}`;
211
+ }
180
212
  /** Creates a property-free callable whose metadata lives only in a `WeakMap`. */
181
213
  function createDependency(definition) {
182
214
  const node = new DependencyNodeImpl(definition);
@@ -748,11 +780,15 @@ var RuntimeImpl = class {
748
780
  this.root = new ScopeImpl(this, void 0, []);
749
781
  }
750
782
  defineDependency(factoryOrOptions, maybeOptions) {
783
+ return this.defineDependencyAt(factoryOrOptions, maybeOptions, captureDefinitionSite(this.defineDependency));
784
+ }
785
+ defineDependencyAt(factoryOrOptions, maybeOptions, definitionSite) {
751
786
  const isFactory = typeof factoryOrOptions === "function";
752
787
  const factory = isFactory ? factoryOrOptions : void 0;
753
788
  const options = (isFactory ? maybeOptions : factoryOrOptions) ?? {};
754
789
  return createDependency({
755
- name: options.name,
790
+ name: options.name ?? (factory?.name || void 0),
791
+ definitionSite,
756
792
  runtime: this,
757
793
  defaultFactory: factory,
758
794
  dispose: options.dispose
@@ -891,8 +927,7 @@ function createRuntime(options = {}) {
891
927
  }
892
928
  const globalRuntime = new RuntimeImpl({ name: "global" });
893
929
  function defineDependency(factoryOrOptions, maybeOptions) {
894
- if (typeof factoryOrOptions === "function") return globalRuntime.defineDependency(factoryOrOptions, maybeOptions);
895
- return globalRuntime.defineDependency(factoryOrOptions);
930
+ return globalRuntime.defineDependencyAt(factoryOrOptions, maybeOptions, captureDefinitionSite(defineDependency));
896
931
  }
897
932
  /**
898
933
  * Installs long-lived providers for module-level dependencies.
package/package.json CHANGED
@@ -1,15 +1,18 @@
1
1
  {
2
2
  "name": "ripple-di",
3
3
  "type": "module",
4
- "version": "1.0.0",
5
- "description": "Scoped dependency injection for TypeScript with automatic dependency tracking and lifecycle-aware cleanup.",
4
+ "version": "1.0.1",
5
+ "description": "Scoped dependency injection for TypeScript with automatic dependency tracking, lifecycle-aware cleanup, and no container lookups in application code.",
6
6
  "keywords": [
7
7
  "dependency-injection",
8
8
  "di",
9
9
  "inversion-of-control",
10
10
  "ioc",
11
+ "service-locator",
11
12
  "typescript",
12
13
  "scoped",
14
+ "dependency-tracking",
15
+ "dependency-graph",
13
16
  "async-context",
14
17
  "asynclocalstorage",
15
18
  "async-hooks",
@@ -22,7 +25,7 @@
22
25
  "engines": {
23
26
  "node": ">=18"
24
27
  },
25
- "packageManager": "bun@1.3.14",
28
+ "packageManager": "bun@1.4.0",
26
29
  "repository": {
27
30
  "type": "git",
28
31
  "url": "git+https://github.com/IlyaSemenov/ripple-di.git"
@@ -56,7 +59,7 @@
56
59
  "@biomejs/biome": "^2.5.5",
57
60
  "@changesets/cli": "^2.31.1",
58
61
  "@tsconfig/bun": "^1.0.10",
59
- "@types/bun": "^1.3.14",
62
+ "@types/bun": "^1.4.0",
60
63
  "publint": "^0.3.22",
61
64
  "tsdown": "^0.22.14",
62
65
  "typescript": "^7.0.2"