ripple-di 1.1.0 → 1.2.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
@@ -61,7 +61,8 @@ The package is published as ESM.
61
61
  | Define an input, derived value, or service | `defineDependency`
62
62
  | Supply the application's values at startup | `install` with `provide` or `provideFactory`
63
63
  | Replace values for one callback | `withOverrides` with `provide` or `provideFactory`
64
- | Run after the current scope can close | `withDetachedOverrides`
64
+ | Continue every current override layer | `withDetachedContext`
65
+ | Run detached with selected overrides | `withDetachedOverrides`
65
66
  | Keep one scope open across several operations | `createScope`
66
67
  | Shut everything down | `dispose`
67
68
 
@@ -173,11 +174,27 @@ Use such a value inside the callback, and use `createScope` when it has to outli
173
174
  Wrapping the value in an object prevents it from being awaited, but the temporary scope still closes before the caller receives it.
174
175
  Anything that scope owned has already been cleaned up.
175
176
 
176
- ### Run outside the current scope
177
+ ### Continue after the current scope closes
177
178
 
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.
179
+ `withDetachedContext` and `withDetachedOverrides` run work outside the current ambient scope, so a request or another scoped operation can close while that work continues.
180
+
181
+ Use `withDetachedContext` to continue with every override layer that is active when you call it:
182
+
183
+ ```ts
184
+ import { withDetachedContext } from "ripple-di"
185
+
186
+ const backgroundTask = withDetachedContext(() =>
187
+ updateTenantSearchIndex(),
188
+ )
189
+
190
+ trackBackgroundTask(backgroundTask)
191
+ ```
192
+
193
+ It reproduces those layers in new scopes without copying cached dependency values.
194
+ Borrowed values keep their identity, while factory provisions run again and their results belong to the new scopes.
195
+ If a layer owns an existing provided value, the call rejects with `DetachedContextOwnedProvisionError` because the value cannot belong to both contexts.
196
+
197
+ Use `withDetachedOverrides` when the work should receive only selected values, especially across a security-sensitive boundary:
181
198
 
182
199
  ```ts
183
200
  import { provide, withDetachedOverrides } from "ripple-di"
@@ -192,12 +209,17 @@ const backgroundTask = withDetachedOverrides(
192
209
  trackBackgroundTask(backgroundTask)
193
210
  ```
194
211
 
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.
212
+ Both functions create scopes beneath the active installation, or beneath the runtime root when no installation is active.
213
+ Closing the installation or calling `dispose()` force-closes them, and an unfinished root child prevents `install()`.
214
+
215
+ The scope remains current while the callback runs and while Ripple DI awaits its result.
216
+ The returned promise settles after cleanup, so code that needs the detached context, including finalization, belongs inside the callback:
198
217
 
199
- The returned promise settles after the callback and cleanup finish.
200
- Keep or observe it so callback and cleanup failures are handled.
218
+ ```ts
219
+ withDetachedContext(() =>
220
+ runBackgroundTask().finally(finalizeBackgroundTask),
221
+ )
222
+ ```
201
223
 
202
224
  ## Where a value belongs
203
225
 
@@ -608,6 +630,7 @@ Every runtime has the same methods, and each has a module-level counterpart that
608
630
  - `resolve`
609
631
  - `createScope`
610
632
  - `withOverrides`
633
+ - `withDetachedContext`
611
634
  - `withDetachedOverrides`
612
635
  - `createValueOverride`
613
636
  - `createOverrideRunner`
package/dist/index.d.mts CHANGED
@@ -196,6 +196,13 @@ interface Runtime {
196
196
  * remains owned by the active installation or runtime root.
197
197
  */
198
198
  withDetachedOverrides<TCallbackResult>(provisions: ProvisionInput, callback: (scope: Scope) => TCallbackResult): Promise<Awaited<TCallbackResult>>;
199
+ /**
200
+ * Continues the current dependency context outside its original scope.
201
+ *
202
+ * The runtime reproduces every current override layer beneath its active
203
+ * installation or root without copying cached dependency values.
204
+ */
205
+ withDetachedContext<TCallbackResult>(callback: (scope: Scope) => TCallbackResult): Promise<Awaited<TCallbackResult>>;
199
206
  /**
200
207
  * Prepares overrides that are applied again to each call of the returned
201
208
  * runner.
@@ -254,6 +261,14 @@ declare function withOverrides<TCallbackResult>(provisions: ProvisionInput, call
254
261
  * and remains part of that lifecycle until the callback finishes.
255
262
  */
256
263
  declare function withDetachedOverrides<TCallbackResult>(provisions: ProvisionInput, callback: (scope: Scope) => TCallbackResult): Promise<Awaited<TCallbackResult>>;
264
+ /**
265
+ * Continues the current dependency context outside its original scope.
266
+ *
267
+ * Current override layers are reproduced beneath the active installation or
268
+ * runtime root without copying cached values, and are cleaned up after the
269
+ * callback finishes.
270
+ */
271
+ declare function withDetachedContext<TCallbackResult>(callback: (scope: Scope) => TCallbackResult): Promise<Awaited<TCallbackResult>>;
257
272
  /**
258
273
  * Prepares dependency overrides that are applied again to each call of the
259
274
  * returned runner.
@@ -333,6 +348,12 @@ declare class OwnedProvisionReuseError extends RippleError {
333
348
  readonly dependencyName: string;
334
349
  constructor(dependencyName: string);
335
350
  }
351
+ /** A detached context cannot reproduce a provision that owns its value. */
352
+ declare class DetachedContextOwnedProvisionError extends RippleError {
353
+ readonly dependencyName: string;
354
+ readonly scopeName: string;
355
+ constructor(dependencyName: string, scopeName: string);
356
+ }
336
357
  /** Dependency factories called one another in a cycle. */
337
358
  declare class DependencyCycleError extends RippleError {
338
359
  readonly path: readonly string[];
@@ -390,4 +411,4 @@ declare class LeakedChildScopeError extends RippleError {
390
411
  constructor(scopeName: string, leakedChildCount: number);
391
412
  }
392
413
  //#endregion
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 };
414
+ export { type AsValue, AsyncFactoryError, CrossRuntimeDependencyError, CrossScopeResolutionError, type Dependency, DependencyCycleError, type DependencyOptions, DetachedContextOwnedProvisionError, 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, withDetachedContext, withDetachedOverrides, withOverrides };
package/dist/index.mjs CHANGED
@@ -58,6 +58,17 @@ var OwnedProvisionReuseError = class extends RippleError {
58
58
  this.name = "OwnedProvisionReuseError";
59
59
  }
60
60
  };
61
+ /** A detached context cannot reproduce a provision that owns its value. */
62
+ var DetachedContextOwnedProvisionError = class extends RippleError {
63
+ dependencyName;
64
+ scopeName;
65
+ constructor(dependencyName, scopeName) {
66
+ super(`Cannot detach context from scope "${scopeName}" because its provision for dependency "${dependencyName}" owns an existing value. Use a borrowed value or factory provision when the context must be reproducible.`);
67
+ this.dependencyName = dependencyName;
68
+ this.scopeName = scopeName;
69
+ this.name = "DetachedContextOwnedProvisionError";
70
+ }
71
+ };
61
72
  /** Dependency factories called one another in a cycle. */
62
73
  var DependencyCycleError = class extends RippleError {
63
74
  path;
@@ -695,6 +706,32 @@ async function withChildScope(parent, provisions, callback) {
695
706
  if (callbackFailed) throw new Error("ripple-di lost a scoped callback error.");
696
707
  return result;
697
708
  }
709
+ /** Reproduces the current scope layers beneath a separate lifecycle parent. */
710
+ async function withDetachedScopeContext(base, current, callback) {
711
+ const snapshots = snapshotDetachedLayers(base, current);
712
+ return await replayDetachedLayers(base, snapshots.length > 0 ? snapshots : [[]], 0, callback);
713
+ }
714
+ /** Captures immutable provider recipes without retaining scope caches. */
715
+ function snapshotDetachedLayers(base, current) {
716
+ if (current.state !== "active") throw new ScopeClosedError("Runtime.withDetachedContext", current.name, current.id, current.state);
717
+ const scopes = [];
718
+ let cursor = current;
719
+ while (cursor !== base) {
720
+ if (!cursor) throw new Error(`Scope "${current.name}" is not beneath base scope "${base.name}".`);
721
+ if (cursor.state === "closing" || cursor.state === "closed") throw new ScopeClosedError("Runtime.withDetachedContext", cursor.name, cursor.id, cursor.state);
722
+ scopes.push(cursor);
723
+ cursor = cursor[scopeParent];
724
+ }
725
+ scopes.reverse();
726
+ for (const scope of scopes) for (const binding of scope.bindings.values()) if (binding.spec.kind === "owned-value") throw new DetachedContextOwnedProvisionError(nodeOf(binding.stamp.dependency).name, scope.name);
727
+ return scopes.map((scope) => [...scope.bindings.values()].map((binding) => binding.spec.kind === "factory" ? provideFactory(binding.stamp.dependency, binding.spec.factory) : provide(binding.stamp.dependency, binding.spec.value)));
728
+ }
729
+ /** Enters reproduced layers from the original outermost layer inward. */
730
+ async function replayDetachedLayers(parent, layers, index, callback) {
731
+ const provisions = layers[index];
732
+ if (!provisions) throw new Error("ripple-di lost a detached context layer.");
733
+ return await withChildScope(parent, provisions, (scope) => index === layers.length - 1 ? callback(scope) : replayDetachedLayers(scope, layers, index + 1, callback));
734
+ }
698
735
  function createDeferred() {
699
736
  let settle;
700
737
  let reject;
@@ -821,6 +858,10 @@ var RuntimeImpl = class {
821
858
  this.assertScopeManagementAllowed("Runtime.withDetachedOverrides");
822
859
  return withChildScope(this.baseScope(), provisions, callback);
823
860
  }
861
+ withDetachedContext(callback) {
862
+ this.assertScopeManagementAllowed("Runtime.withDetachedContext");
863
+ return withDetachedScopeContext(this.baseScope(), this.currentAmbientScope(), callback);
864
+ }
824
865
  createOverrideRunner(factory) {
825
866
  return createOverrideRunnerFor(this, factory);
826
867
  }
@@ -969,6 +1010,16 @@ function withDetachedOverrides(provisions, callback) {
969
1010
  return globalRuntime.withDetachedOverrides(provisions, callback);
970
1011
  }
971
1012
  /**
1013
+ * Continues the current dependency context outside its original scope.
1014
+ *
1015
+ * Current override layers are reproduced beneath the active installation or
1016
+ * runtime root without copying cached values, and are cleaned up after the
1017
+ * callback finishes.
1018
+ */
1019
+ function withDetachedContext(callback) {
1020
+ return globalRuntime.withDetachedContext(callback);
1021
+ }
1022
+ /**
972
1023
  * Prepares dependency overrides that are applied again to each call of the
973
1024
  * returned runner.
974
1025
  *
@@ -993,4 +1044,4 @@ function dispose() {
993
1044
  return globalRuntime.dispose();
994
1045
  }
995
1046
  //#endregion
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 };
1047
+ export { AsyncFactoryError, CrossRuntimeDependencyError, CrossScopeResolutionError, DependencyCycleError, DetachedContextOwnedProvisionError, DisposerContextError, DuplicateProviderError, FactoryError, FactoryScopeOperationError, InstallationConflictError, LeakedChildScopeError, MissingProviderError, OwnedProvisionReuseError, RippleError, ScopeClosedError, asValue, createOverrideRunner, createRuntime, createScope, createValueOverride, defineDependency, dispose, install, provide, provideFactory, resolve, withDetachedContext, withDetachedOverrides, withOverrides };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ripple-di",
3
3
  "type": "module",
4
- "version": "1.1.0",
4
+ "version": "1.2.0",
5
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",