ripple-di 1.0.0 → 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.
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,23 @@ 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
+ | Run after the current scope can close | `withDetachedOverrides`
65
+ | Keep one scope open across several operations | `createScope`
66
+ | Shut everything down | `dispose`
67
+
68
+ `createValueOverride` and `createOverrideRunner` in [Advanced usage](#advanced-usage) turn overrides you write repeatedly into reusable helpers.
101
69
 
102
70
  ## Define dependencies
103
71
 
72
+ Define each shared input and service once, in the module that owns it, and import it wherever it is used.
73
+
104
74
  Call `defineDependency<T>()` without a factory when a value must come from the application, request, or task boundary.
105
75
 
106
76
  ```ts
@@ -109,7 +79,7 @@ import { defineDependency, provide, withOverrides } from "ripple-di"
109
79
  const useTenant = defineDependency<Tenant>()
110
80
 
111
81
  await withOverrides(
112
- [provide(useTenant, tenant)],
82
+ provide(useTenant, tenant),
113
83
  () => processRequest(),
114
84
  )
115
85
  ```
@@ -117,29 +87,57 @@ await withOverrides(
117
87
  Reading it without a provider throws `MissingProviderError`.
118
88
  Pass a factory when the dependency has a built-in value.
119
89
  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)`.
90
+ A function in the first argument is always the factory, so a dependency whose value is itself a function wraps it: `defineDependency(() => handler)`.
121
91
 
122
92
  ```ts
123
93
  const useClock = defineDependency(() => systemClock)
124
94
  const usePublicUrl = defineDependency(() => createPublicUrl(useConfig()))
125
95
  ```
126
96
 
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.
97
+ The result is cached, and a scope that overrides one of the dependencies it read gets a separate result.
98
+ Its type comes from the factory, so `useDb()` returns whatever `createDb` returns.
99
+ Add a `dispose` callback when values owned by Ripple DI need cleanup, as shown by `useDb` in the example above.
100
+
101
+ A factory can read dependencies, but cannot create, enter, close, or retire scopes and installations in its own runtime; misuse throws `FactoryScopeOperationError`.
102
+
103
+ ### Promises and thenables
104
+
105
+ A factory returns the value itself, synchronously; disposers may be asynchronous.
106
+
107
+ ```ts
108
+ import { asValue, defineDependency } from "ripple-di"
109
+
110
+ // The usual case: the factory returns the value.
111
+ defineDependency(() => createSessionStore())
112
+
113
+ // Reading this throws AsyncFactoryError: reads made after an await are not tracked.
114
+ defineDependency(async () => loadSession())
115
+
116
+ // The promise itself is the cached dependency value.
117
+ defineDependency(() => asValue(loadSession()))
118
+
119
+ // A query builder implements `then` but is an ordinary value.
120
+ defineDependency(() => selectSessions())
121
+ ```
122
+
123
+ A factory is rejected only when it returns a native `Promise` object, so a value that merely implements `then` is stored as it is.
124
+ Wrap the result in `asValue` when the promise itself is the value the dependency holds.
125
+
126
+ `asValue` marks the promise as the value and does nothing else.
127
+ 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
128
 
130
129
  ## Override dependencies
131
130
 
132
131
  `withOverrides` runs a callback with temporary values and cleans up whatever it created for that callback.
132
+ Pass one `provide` or `provideFactory` directly.
133
133
 
134
134
  ```ts
135
135
  import { provide, withOverrides } from "ripple-di"
136
136
 
137
137
  const users = await withOverrides(
138
- [
139
- provide(useConfig, {
140
- databaseUrl: "postgres://localhost/test",
141
- }),
142
- ],
138
+ provide(useConfig, {
139
+ databaseUrl: "postgres://localhost/test",
140
+ }),
143
141
  () => loadUsers(),
144
142
  )
145
143
  ```
@@ -159,43 +157,107 @@ await withOverrides(
159
157
  )
160
158
  ```
161
159
 
162
- For one dependency, you can omit the array:
160
+ Overrides survive `await` and stay isolated between parallel callbacks.
163
161
 
164
162
  ```ts
165
- await withOverrides(
166
- provide(useConfig, testConfig),
167
- () => loadUsers(),
168
- )
163
+ const [leftUsers, rightUsers] = await Promise.all([
164
+ withOverrides(provide(useConfig, leftConfig), () => loadUsers()),
165
+ withOverrides(provide(useConfig, rightConfig), () => loadUsers()),
166
+ ])
169
167
  ```
170
168
 
171
- Overrides survive `await` and stay isolated between parallel callbacks.
169
+ ### Awaitable callback results
170
+
171
+ `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.
172
+ Use such a value inside the callback, and use `createScope` when it has to outlive the callback.
173
+ Wrapping the value in an object prevents it from being awaited, but the temporary scope still closes before the caller receives it.
174
+ Anything that scope owned has already been cleaned up.
175
+
176
+ ### Run outside the current scope
177
+
178
+ `withDetachedOverrides` runs work in a temporary scope that does not inherit the current ambient scope.
179
+ Use it when work must continue after a request or another scoped operation can finish.
180
+ Capture every request value the work needs and provide it explicitly.
172
181
 
173
182
  ```ts
174
- const [leftUsers, rightUsers] = await Promise.all([
175
- withOverrides([provide(useConfig, leftConfig)], () => loadUsers()),
176
- withOverrides([provide(useConfig, rightConfig)], () => loadUsers()),
177
- ])
183
+ import { provide, withDetachedOverrides } from "ripple-di"
184
+
185
+ const tenant = useTenant()
186
+
187
+ const backgroundTask = withDetachedOverrides(
188
+ provide(useTenant, tenant),
189
+ () => updateTenantSearchIndex(),
190
+ )
191
+
192
+ trackBackgroundTask(backgroundTask)
178
193
  ```
179
194
 
180
- ### Name an override you write repeatedly
195
+ The detached scope inherits from the active installation, or from the runtime root when no installation is active.
196
+ It remains part of that lifecycle: closing the installation or calling `dispose()` force-closes it.
197
+ When no installation is active, an unfinished detached scope also prevents `install()` until its callback and cleanup finish.
198
+
199
+ The returned promise settles after the callback and cleanup finish.
200
+ Keep or observe it so callback and cleanup failures are handled.
181
201
 
182
- When the same dependency is supplied the same way all over the application, `createValueOverride` turns that into one named helper.
202
+ ## Where a value belongs
203
+
204
+ The same tracked dependency calls that decide when a factory result must be rebuilt also decide which lifecycle owns it and invokes its disposer.
205
+ A factory-created value belongs to the innermost scope that supplied either its factory or one of the dependencies it read.
206
+ 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.
207
+
208
+ **Reading a dependency inside a scope does not make its value scope-local.**
183
209
 
184
210
  ```ts
185
- import { createValueOverride } from "ripple-di"
211
+ // Wrong: every request shares this one transaction.
212
+ const useTransaction = defineDependency(
213
+ () => useDb().transaction(),
214
+ { dispose: tx => tx.rollback() },
215
+ )
216
+ ```
186
217
 
187
- const withOpenAiClient = createValueOverride(useOpenAiClient)
218
+ `useDb` belongs to the application, so a transaction built from it belongs to the application too.
219
+ Every request reads the same transaction, and it is rolled back at shutdown rather than when the request ends.
188
220
 
189
- await withOpenAiClient(client, () => createTextEmbedding(text))
221
+ For a value that belongs to one operation, define the dependency without a built-in factory.
222
+ Provide its factory when you create the scope of that operation:
223
+
224
+ ```ts
225
+ const useTransaction = defineDependency<Transaction>({
226
+ dispose: tx => tx.rollback(),
227
+ })
228
+
229
+ await withOverrides(
230
+ provideFactory(useTransaction, () => useDb().transaction()),
231
+ () => processRequest(),
232
+ )
190
233
  ```
191
234
 
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.
235
+ Each call now builds its own transaction, still lazily, and the scope rolls it back when the callback finishes.
236
+ Reading `useTransaction()` outside such a scope throws `MissingProviderError` instead of quietly sharing one.
237
+
238
+ 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.
239
+
240
+ 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
241
 
195
242
  ```ts
196
- const withConnection = createValueOverride(useConnection, { dispose: true })
243
+ const useBuiltInConfig = defineDependency(() => rootConfig)
244
+ const useRootPool = defineDependency(() => createPool(useBuiltInConfig()), {
245
+ dispose: pool => pool.end(),
246
+ })
247
+
248
+ const useInstalledConfig = defineDependency<DatabaseConfig>()
249
+ const useInstalledPool = defineDependency(() => createPool(useInstalledConfig()), {
250
+ dispose: pool => pool.end(),
251
+ })
252
+
253
+ const installation = install(provide(useInstalledConfig, installedConfig))
254
+ useRootPool()
255
+ useInstalledPool()
256
+ await installation.close() // Before installing replacement providers.
197
257
  ```
198
258
 
259
+ The installed pool closes with the installation, while the root pool remains cached until `dispose()` because its factory read only built-in dependencies.
260
+
199
261
  ## Wire the application at startup
200
262
 
201
263
  Some dependencies get their real value only when the application starts.
@@ -211,11 +273,11 @@ export const useTenantResolver = defineDependency<TenantResolver>()
211
273
 
212
274
  ```ts
213
275
  // startup.ts
214
- import { install, provide } from "ripple-di"
276
+ import { dispose, install, provide } from "ripple-di"
215
277
 
216
278
  import { useTenantResolver, useWebConfig } from "./dependencies"
217
279
 
218
- const installation = install([
280
+ install([
219
281
  provide(useWebConfig, config),
220
282
  provide(useTenantResolver, resolveTenant),
221
283
  ])
@@ -223,20 +285,25 @@ const installation = install([
223
285
  try {
224
286
  await runApplication()
225
287
  } finally {
226
- await installation.close()
288
+ await dispose()
227
289
  }
228
290
  ```
229
291
 
230
292
  Installed providers are the fallback everywhere: request handlers, background jobs, tests, and any other code running outside a scope.
231
293
  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.
294
+ Closing an installation removes its providers and cleans up values whose factory or tracked dependencies belong to it.
295
+ It keeps the runtime usable, so application-owned values remain cached for later installations.
296
+ Use `installation.close()` before a controlled replacement; use `dispose()` to shut the application down.
233
297
 
234
298
  - Installing while another installation or any scope is still open throws `InstallationConflictError`, whose message says what is still open.
235
299
  Await `installation.close()` before installing a replacement.
236
300
  - 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.
301
+ - Install once during startup.
302
+ 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.
303
+ - 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`.
304
+ This keeps the factory body in its owning module, but removes the built-in fallback: reading the dependency without that provision throws `MissingProviderError`.
238
305
  - Every worker thread and every process wires its own installation.
239
- - In tests, install once for the whole process and use `withOverrides` per test.
306
+ - In tests, install once for the whole process and override per test, as shown in [Test your application](#test-your-application).
240
307
 
241
308
  ### Collect the providers of several modules
242
309
 
@@ -264,17 +331,19 @@ export function getPlatformProvisions() {
264
331
 
265
332
  ```ts
266
333
  // startup.ts
267
- const installation = install([
334
+ import { dispose, install } from "ripple-di"
335
+
336
+ install([
268
337
  ...getCoreProvisions(),
269
338
  ...getPlatformProvisions(),
270
339
  ])
271
340
 
272
- onShutdown(() => installation.close())
341
+ onShutdown(() => dispose())
273
342
  ```
274
343
 
275
344
  - Export a function that builds the provisions rather than a ready-made array, so each installation gets provisions of its own.
276
345
  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.
346
+ - The composition root that installs the provisions also calls `dispose()` from the shutdown hook the application already has.
278
347
  - Tests have the same single composition root: one preload installs the provisions of every layer the suite needs.
279
348
  Separate preloads installing on their own would fail on the second `install` instead of adding their providers.
280
349
 
@@ -282,6 +351,7 @@ onShutdown(() => installation.close())
282
351
 
283
352
  Call `dispose()` when the application shuts down.
284
353
  It closes every scope and cleans up every owned value still held by the module-level API, including an active installation.
354
+ Closing only the installation can leave [application-owned values](#where-a-value-belongs) cached.
285
355
 
286
356
  ```ts
287
357
  import { dispose } from "ripple-di"
@@ -294,6 +364,66 @@ Values created inside `withOverrides` never wait for shutdown; they are cleaned
294
364
  `dispose()` is final: afterwards the runtime cannot resolve dependencies, create scopes, or install providers, and those calls throw `ScopeClosedError`.
295
365
  Do not use it to reset state between tests — use `withOverrides` for that, or create a separate runtime for each lifecycle.
296
366
 
367
+ ## Test your application
368
+
369
+ 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.
370
+
371
+ ```ts
372
+ import {
373
+ createOverrideRunner,
374
+ createScope,
375
+ provide,
376
+ type Scope,
377
+ withOverrides,
378
+ } from "ripple-di"
379
+
380
+ test("loads users from the test database", () =>
381
+ withOverrides(
382
+ provide(useConfig, testConfig),
383
+ () => loadUsers(),
384
+ ),
385
+ )
386
+ ```
387
+
388
+ The scope lives exactly as long as that callback, so tests running in parallel cannot see one another's overrides.
389
+
390
+ When several tests share one set of values that is expensive to build, create the scope once and run each test inside it.
391
+
392
+ ```ts
393
+ let scope: Scope
394
+
395
+ beforeAll(() => {
396
+ scope = createScope(provide(useConfig, testConfig))
397
+ })
398
+
399
+ afterAll(() => scope.close())
400
+
401
+ test("lists users", () => scope.run(() => listUsers()))
402
+ test("creates a user", () => scope.run(() => createUser()))
403
+ ```
404
+
405
+ Entering a scope in a hook does not carry it into the tests that follow.
406
+
407
+ ```ts
408
+ // Wrong: the scope is current inside this callback and nowhere else.
409
+ beforeAll(() => scope.run(() => {}))
410
+ ```
411
+
412
+ - `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.
413
+ - Tests that share a suite scope also share the values cached in it, so keep that recipe for values they can safely reuse.
414
+ - `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`.
415
+
416
+ When every test in a file needs the same overrides in a separate scope, a runner applies them to each test independently.
417
+
418
+ ```ts
419
+ const testConfigOverrides = createOverrideRunner(() =>
420
+ provide(useConfig, testConfig),
421
+ )
422
+
423
+ test("lists users", testConfigOverrides.wrap(() => listUsers()))
424
+ test("creates a user", testConfigOverrides.wrap(() => createUser()))
425
+ ```
426
+
297
427
  ## Advanced usage
298
428
 
299
429
  Everything below is optional.
@@ -320,6 +450,7 @@ provide(useDb, fakeDb, { dispose: db => db.closeImmediately() })
320
450
 
321
451
  A function passed to `provide` stays an ordinary function value; use `provideFactory` when it should build the value instead.
322
452
  The dependencies an override factory reads are tracked like the ones read by the factory it replaces.
453
+ An override factory cannot read the previous value of the dependency it replaces; define a base dependency and a decorated one instead.
323
454
  `dispose: true` reuses the disposer from `defineDependency`, so it throws right away when the dependency declares none.
324
455
 
325
456
  A provision that hands over ownership belongs to a single scope or installation.
@@ -332,15 +463,13 @@ const useQueue = defineDependency<Queue>({
332
463
  dispose: queue => queue.close(),
333
464
  })
334
465
 
335
- const installation = install([
336
- provideFactory(useQueue, () => createQueue(queueUrl)),
337
- ])
466
+ install(provideFactory(useQueue, () => createQueue(queueUrl)))
338
467
 
339
- // Later, at shutdown:
340
- await installation.close()
468
+ // Later, at application shutdown:
469
+ await dispose()
341
470
  ```
342
471
 
343
- The installed factory is lazy, and its queue client is closed with the installation.
472
+ 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
473
 
345
474
  ### Manage a scope explicitly
346
475
 
@@ -350,9 +479,7 @@ Use `createScope` when several operations share the same overrides and close at
350
479
  ```ts
351
480
  import { createScope, provide } from "ripple-di"
352
481
 
353
- const scope = createScope([
354
- provide(useConfig, tenantConfig),
355
- ])
482
+ const scope = createScope(provide(useConfig, tenantConfig))
356
483
 
357
484
  try {
358
485
  await scope.run(() => processTenant())
@@ -361,13 +488,36 @@ try {
361
488
  }
362
489
  ```
363
490
 
364
- - `scope.run` makes the scope current for a callback without closing it.
491
+ - `scope.run` makes the scope current for a callback without closing it, and returns the callback's result unchanged.
365
492
  - `scope.resolve(useDb)` reads a dependency from that scope rather than the current one.
493
+ A factory reads from its own scope only, so calling `scope.resolve` on a different scope inside one throws `CrossScopeResolutionError`.
366
494
  - `scope.createScope` and `scope.withOverrides` create children of that scope instead of the current one.
367
495
  - A child of the scope that `withOverrides` created must be closed before the callback returns, otherwise Ripple DI closes it and throws `LeakedChildScopeError`.
368
496
  - `scope.close()` closes the scope and everything below it, while `scope.retire()` waits for child scopes to finish first.
369
497
  - Closing disposes what the scope itself created; reused application values stay open.
370
498
  - Cleanup continues past a failing disposer and reports every failure in one `AggregateError`.
499
+ - A disposer, and any async work it starts, cannot read dependencies or manage scopes and installations in the runtime being closed; misuse throws `DisposerContextError`.
500
+ Put everything cleanup needs into the dependency value itself.
501
+
502
+ ### Name an override you write repeatedly
503
+
504
+ When a dependency is replaced in only one place, keep `withOverrides` with one `provide` at the call site.
505
+ For repeated replacements, `createValueOverride` turns the pattern into a named helper.
506
+
507
+ ```ts
508
+ import { createValueOverride } from "ripple-di"
509
+
510
+ const withOpenAiClient = createValueOverride(useOpenAiClient)
511
+
512
+ await withOpenAiClient(client, () => createTextEmbedding(text))
513
+ ```
514
+
515
+ Each call supplies the value it is given to one callback, exactly like writing `withOverrides` with a single `provide` by hand.
516
+ Pass the ownership options once, and every call cleans its own value up.
517
+
518
+ ```ts
519
+ const withConnection = createValueOverride(useConnection, { dispose: true })
520
+ ```
371
521
 
372
522
  ### Reuse one set of overrides
373
523
 
@@ -377,9 +527,9 @@ A long-lived object such as an API caller or a job worker is created once, but e
377
527
  ```ts
378
528
  import { createOverrideRunner, provide } from "ripple-di"
379
529
 
380
- const jobOverrides = createOverrideRunner(() => [
530
+ const jobOverrides = createOverrideRunner(() =>
381
531
  provide(useJobContext, createJobContext(), { dispose: true }),
382
- ])
532
+ )
383
533
 
384
534
  const worker = createWorker(job => jobOverrides.run(() => handleJob(job)))
385
535
  ```
@@ -403,9 +553,9 @@ That also suits a file of independent test cases that share one set of overrides
403
553
  `extend` returns a runner with one more layer of overrides and leaves the runner it extends unchanged:
404
554
 
405
555
  ```ts
406
- const tenantOverrides = jobOverrides.extend(() => [
556
+ const tenantOverrides = jobOverrides.extend(() =>
407
557
  provide(useTenantResolver, tenantResolver),
408
- ])
558
+ )
409
559
 
410
560
  await tenantOverrides.run(() => handleRequest())
411
561
  ```
@@ -449,6 +599,7 @@ await Promise.all([
449
599
  ```
450
600
 
451
601
  A dependency belongs to the runtime that defined it and cannot be read or overridden in another one.
602
+ 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
603
  Pass `name` to `createRuntime` to see that name in error messages.
453
604
  Every runtime has the same methods, and each has a module-level counterpart that targets the built-in runtime:
454
605
 
@@ -457,6 +608,7 @@ Every runtime has the same methods, and each has a module-level counterpart that
457
608
  - `resolve`
458
609
  - `createScope`
459
610
  - `withOverrides`
611
+ - `withDetachedOverrides`
460
612
  - `createValueOverride`
461
613
  - `createOverrideRunner`
462
614
  - `dispose`
@@ -471,36 +623,31 @@ Errors thrown by your own factory arrive wrapped in `FactoryError` with the orig
471
623
  A failed factory is not cached, so the next read tries again.
472
624
 
473
625
  These errors name the dependencies involved.
474
- Pass `name` to give one a readable label instead of a generated one:
626
+ An explicit `name` is always used when you provide one:
475
627
 
476
628
  ```ts
477
629
  const useConfig = defineDependency(loadConfig, { name: "config" })
478
630
  ```
479
631
 
480
632
  The name only affects messages.
633
+ 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)`.
634
+ 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
635
 
482
- ## Limits
636
+ ## Do not mix package copies
637
+
638
+ Two copies of Ripple DI, separately installed or bundled, each run their own graph and cannot be combined.
639
+ Do not pass dependencies or provisions between them, and do not call one copy's dependency inside a factory owned by the other.
640
+ Such a call is not detected: the dependency may resolve against its own copy instead of failing at the boundary.
641
+
642
+ Declare `ripple-di` as a peer dependency in any package that exports dependencies of its own.
643
+
644
+ ## Caveats
483
645
 
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
646
  - 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.
647
+ Everything else stays invisible: `process.env`, `Date.now()`, and any dependency called after the factory has returned.
500
648
  - Read dependencies where you use them.
501
649
  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.
650
+ - 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
651
 
505
652
  ## License
506
653
 
package/dist/index.d.mts CHANGED
@@ -189,6 +189,13 @@ interface Runtime {
189
189
  * for the callback afterward.
190
190
  */
191
191
  withOverrides<TCallbackResult>(provisions: ProvisionInput, callback: (scope: Scope) => TCallbackResult): Promise<Awaited<TCallbackResult>>;
192
+ /**
193
+ * Runs a callback in a temporary child of the runtime's current base scope.
194
+ *
195
+ * The callback does not inherit the current ambient scope, but its scope
196
+ * remains owned by the active installation or runtime root.
197
+ */
198
+ withDetachedOverrides<TCallbackResult>(provisions: ProvisionInput, callback: (scope: Scope) => TCallbackResult): Promise<Awaited<TCallbackResult>>;
192
199
  /**
193
200
  * Prepares overrides that are applied again to each call of the returned
194
201
  * runner.
@@ -240,6 +247,13 @@ declare function createScope(provisions?: ProvisionInput): Scope;
240
247
  * callbacks, and are cleaned up when the callback finishes.
241
248
  */
242
249
  declare function withOverrides<TCallbackResult>(provisions: ProvisionInput, callback: (scope: Scope) => TCallbackResult): Promise<Awaited<TCallbackResult>>;
250
+ /**
251
+ * Runs a callback with overrides outside the current ambient scope.
252
+ *
253
+ * The temporary scope inherits from the active installation or runtime root
254
+ * and remains part of that lifecycle until the callback finishes.
255
+ */
256
+ declare function withDetachedOverrides<TCallbackResult>(provisions: ProvisionInput, callback: (scope: Scope) => TCallbackResult): Promise<Awaited<TCallbackResult>>;
243
257
  /**
244
258
  * Prepares dependency overrides that are applied again to each call of the
245
259
  * returned runner.
@@ -376,4 +390,4 @@ declare class LeakedChildScopeError extends RippleError {
376
390
  constructor(scopeName: string, leakedChildCount: number);
377
391
  }
378
392
  //#endregion
379
- export { type AsValue, AsyncFactoryError, CrossRuntimeDependencyError, CrossScopeResolutionError, type Dependency, DependencyCycleError, type DependencyOptions, type Disposer, DisposerContextError, DuplicateProviderError, FactoryError, type FactoryResult, FactoryScopeOperationError, Installation, InstallationConflictError, LeakedChildScopeError, MissingProviderError, type OverrideRunner, OwnedProvisionReuseError, type ProvideOptions, type Provision, type ProvisionFactory, type ProvisionInput, RippleError, Runtime, RuntimeOptions, type Scope, ScopeClosedError, type ScopeState, type ValueOverride, asValue, createOverrideRunner, createRuntime, createScope, createValueOverride, defineDependency, dispose, install, provide, provideFactory, resolve, withOverrides };
393
+ export { type AsValue, AsyncFactoryError, CrossRuntimeDependencyError, CrossScopeResolutionError, type Dependency, DependencyCycleError, type DependencyOptions, type Disposer, DisposerContextError, DuplicateProviderError, FactoryError, type FactoryResult, FactoryScopeOperationError, Installation, InstallationConflictError, LeakedChildScopeError, MissingProviderError, type OverrideRunner, OwnedProvisionReuseError, type ProvideOptions, type Provision, type ProvisionFactory, type ProvisionInput, RippleError, Runtime, RuntimeOptions, type Scope, ScopeClosedError, type ScopeState, type ValueOverride, asValue, createOverrideRunner, createRuntime, createScope, createValueOverride, defineDependency, dispose, install, provide, provideFactory, resolve, withDetachedOverrides, withOverrides };
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
@@ -781,6 +817,10 @@ var RuntimeImpl = class {
781
817
  this.assertScopeManagementAllowed("Runtime.withOverrides");
782
818
  return withChildScope(this.currentAmbientScope(), provisions, callback);
783
819
  }
820
+ withDetachedOverrides(provisions, callback) {
821
+ this.assertScopeManagementAllowed("Runtime.withDetachedOverrides");
822
+ return withChildScope(this.baseScope(), provisions, callback);
823
+ }
784
824
  createOverrideRunner(factory) {
785
825
  return createOverrideRunnerFor(this, factory);
786
826
  }
@@ -891,8 +931,7 @@ function createRuntime(options = {}) {
891
931
  }
892
932
  const globalRuntime = new RuntimeImpl({ name: "global" });
893
933
  function defineDependency(factoryOrOptions, maybeOptions) {
894
- if (typeof factoryOrOptions === "function") return globalRuntime.defineDependency(factoryOrOptions, maybeOptions);
895
- return globalRuntime.defineDependency(factoryOrOptions);
934
+ return globalRuntime.defineDependencyAt(factoryOrOptions, maybeOptions, captureDefinitionSite(defineDependency));
896
935
  }
897
936
  /**
898
937
  * Installs long-lived providers for module-level dependencies.
@@ -921,6 +960,15 @@ function withOverrides(provisions, callback) {
921
960
  return globalRuntime.withOverrides(provisions, callback);
922
961
  }
923
962
  /**
963
+ * Runs a callback with overrides outside the current ambient scope.
964
+ *
965
+ * The temporary scope inherits from the active installation or runtime root
966
+ * and remains part of that lifecycle until the callback finishes.
967
+ */
968
+ function withDetachedOverrides(provisions, callback) {
969
+ return globalRuntime.withDetachedOverrides(provisions, callback);
970
+ }
971
+ /**
924
972
  * Prepares dependency overrides that are applied again to each call of the
925
973
  * returned runner.
926
974
  *
@@ -945,4 +993,4 @@ function dispose() {
945
993
  return globalRuntime.dispose();
946
994
  }
947
995
  //#endregion
948
- export { AsyncFactoryError, CrossRuntimeDependencyError, CrossScopeResolutionError, DependencyCycleError, DisposerContextError, DuplicateProviderError, FactoryError, FactoryScopeOperationError, InstallationConflictError, LeakedChildScopeError, MissingProviderError, OwnedProvisionReuseError, RippleError, ScopeClosedError, asValue, createOverrideRunner, createRuntime, createScope, createValueOverride, defineDependency, dispose, install, provide, provideFactory, resolve, withOverrides };
996
+ export { AsyncFactoryError, CrossRuntimeDependencyError, CrossScopeResolutionError, DependencyCycleError, DisposerContextError, DuplicateProviderError, FactoryError, FactoryScopeOperationError, InstallationConflictError, LeakedChildScopeError, MissingProviderError, OwnedProvisionReuseError, RippleError, ScopeClosedError, asValue, createOverrideRunner, createRuntime, createScope, createValueOverride, defineDependency, dispose, install, provide, provideFactory, resolve, withDetachedOverrides, withOverrides };
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.1.0",
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"