ripple-di 1.0.1 → 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,6 +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
+ | Continue every current override layer | `withDetachedContext`
65
+ | Run detached with selected overrides | `withDetachedOverrides`
64
66
  | Keep one scope open across several operations | `createScope`
65
67
  | Shut everything down | `dispose`
66
68
 
@@ -172,6 +174,53 @@ Use such a value inside the callback, and use `createScope` when it has to outli
172
174
  Wrapping the value in an object prevents it from being awaited, but the temporary scope still closes before the caller receives it.
173
175
  Anything that scope owned has already been cleaned up.
174
176
 
177
+ ### Continue after the current scope closes
178
+
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:
198
+
199
+ ```ts
200
+ import { provide, withDetachedOverrides } from "ripple-di"
201
+
202
+ const tenant = useTenant()
203
+
204
+ const backgroundTask = withDetachedOverrides(
205
+ provide(useTenant, tenant),
206
+ () => updateTenantSearchIndex(),
207
+ )
208
+
209
+ trackBackgroundTask(backgroundTask)
210
+ ```
211
+
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:
217
+
218
+ ```ts
219
+ withDetachedContext(() =>
220
+ runBackgroundTask().finally(finalizeBackgroundTask),
221
+ )
222
+ ```
223
+
175
224
  ## Where a value belongs
176
225
 
177
226
  The same tracked dependency calls that decide when a factory result must be rebuilt also decide which lifecycle owns it and invokes its disposer.
@@ -581,6 +630,8 @@ Every runtime has the same methods, and each has a module-level counterpart that
581
630
  - `resolve`
582
631
  - `createScope`
583
632
  - `withOverrides`
633
+ - `withDetachedContext`
634
+ - `withDetachedOverrides`
584
635
  - `createValueOverride`
585
636
  - `createOverrideRunner`
586
637
  - `dispose`
package/dist/index.d.mts CHANGED
@@ -189,6 +189,20 @@ 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>>;
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>>;
192
206
  /**
193
207
  * Prepares overrides that are applied again to each call of the returned
194
208
  * runner.
@@ -240,6 +254,21 @@ declare function createScope(provisions?: ProvisionInput): Scope;
240
254
  * callbacks, and are cleaned up when the callback finishes.
241
255
  */
242
256
  declare function withOverrides<TCallbackResult>(provisions: ProvisionInput, callback: (scope: Scope) => TCallbackResult): Promise<Awaited<TCallbackResult>>;
257
+ /**
258
+ * Runs a callback with overrides outside the current ambient scope.
259
+ *
260
+ * The temporary scope inherits from the active installation or runtime root
261
+ * and remains part of that lifecycle until the callback finishes.
262
+ */
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>>;
243
272
  /**
244
273
  * Prepares dependency overrides that are applied again to each call of the
245
274
  * returned runner.
@@ -319,6 +348,12 @@ declare class OwnedProvisionReuseError extends RippleError {
319
348
  readonly dependencyName: string;
320
349
  constructor(dependencyName: string);
321
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
+ }
322
357
  /** Dependency factories called one another in a cycle. */
323
358
  declare class DependencyCycleError extends RippleError {
324
359
  readonly path: readonly string[];
@@ -376,4 +411,4 @@ declare class LeakedChildScopeError extends RippleError {
376
411
  constructor(scopeName: string, leakedChildCount: number);
377
412
  }
378
413
  //#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 };
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;
@@ -817,6 +854,14 @@ var RuntimeImpl = class {
817
854
  this.assertScopeManagementAllowed("Runtime.withOverrides");
818
855
  return withChildScope(this.currentAmbientScope(), provisions, callback);
819
856
  }
857
+ withDetachedOverrides(provisions, callback) {
858
+ this.assertScopeManagementAllowed("Runtime.withDetachedOverrides");
859
+ return withChildScope(this.baseScope(), provisions, callback);
860
+ }
861
+ withDetachedContext(callback) {
862
+ this.assertScopeManagementAllowed("Runtime.withDetachedContext");
863
+ return withDetachedScopeContext(this.baseScope(), this.currentAmbientScope(), callback);
864
+ }
820
865
  createOverrideRunner(factory) {
821
866
  return createOverrideRunnerFor(this, factory);
822
867
  }
@@ -956,6 +1001,25 @@ function withOverrides(provisions, callback) {
956
1001
  return globalRuntime.withOverrides(provisions, callback);
957
1002
  }
958
1003
  /**
1004
+ * Runs a callback with overrides outside the current ambient scope.
1005
+ *
1006
+ * The temporary scope inherits from the active installation or runtime root
1007
+ * and remains part of that lifecycle until the callback finishes.
1008
+ */
1009
+ function withDetachedOverrides(provisions, callback) {
1010
+ return globalRuntime.withDetachedOverrides(provisions, callback);
1011
+ }
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
+ /**
959
1023
  * Prepares dependency overrides that are applied again to each call of the
960
1024
  * returned runner.
961
1025
  *
@@ -980,4 +1044,4 @@ function dispose() {
980
1044
  return globalRuntime.dispose();
981
1045
  }
982
1046
  //#endregion
983
- 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 };
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.0.1",
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",