sandly 0.4.0 → 0.5.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 (4) hide show
  1. package/README.md +188 -43
  2. package/dist/index.d.ts +161 -137
  3. package/dist/index.js +131 -169
  4. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,5 +1,3 @@
1
- import { AsyncLocalStorage } from "node:async_hooks";
2
-
3
1
  //#region src/utils/object.ts
4
2
  function hasKey(obj, key) {
5
3
  return obj !== void 0 && obj !== null && (typeof obj === "object" || typeof obj === "function") && key in obj;
@@ -75,7 +73,25 @@ const InjectSource = "sandly/InjectSource";
75
73
 
76
74
  //#endregion
77
75
  //#region src/errors.ts
78
- var BaseError = class BaseError extends Error {
76
+ /**
77
+ * Base error class for all library errors.
78
+ *
79
+ * This extends the native Error class to provide consistent error handling
80
+ * and structured error information across the library.
81
+ *
82
+ * @example Catching library errors
83
+ * ```typescript
84
+ * try {
85
+ * await container.resolve(SomeService);
86
+ * } catch (error) {
87
+ * if (error instanceof SandlyError) {
88
+ * console.error('DI Error:', error.message);
89
+ * console.error('Details:', error.detail);
90
+ * }
91
+ * }
92
+ * ```
93
+ */
94
+ var SandlyError = class SandlyError extends Error {
79
95
  detail;
80
96
  constructor(message, { cause, detail } = {}) {
81
97
  super(message, { cause });
@@ -84,10 +100,10 @@ var BaseError = class BaseError extends Error {
84
100
  if (cause instanceof Error && cause.stack !== void 0) this.stack = `${this.stack}\nCaused by: ${cause.stack}`;
85
101
  }
86
102
  static ensure(error) {
87
- return error instanceof BaseError ? error : new BaseError("An unknown error occurred", { cause: error });
103
+ return error instanceof SandlyError ? error : new SandlyError("An unknown error occurred", { cause: error });
88
104
  }
89
105
  dump() {
90
- const cause = this.cause instanceof BaseError ? this.cause.dump().error : this.cause;
106
+ const cause = this.cause instanceof SandlyError ? this.cause.dump().error : this.cause;
91
107
  const result = {
92
108
  name: this.name,
93
109
  message: this.message,
@@ -106,32 +122,13 @@ var BaseError = class BaseError extends Error {
106
122
  }
107
123
  };
108
124
  /**
109
- * Base error class for all dependency container related errors.
110
- *
111
- * This extends the framework's BaseError to provide consistent error handling
112
- * and structured error information across the dependency injection system.
113
- *
114
- * @example Catching DI errors
115
- * ```typescript
116
- * try {
117
- * await container.resolve(SomeService);
118
- * } catch (error) {
119
- * if (error instanceof ContainerError) {
120
- * console.error('DI Error:', error.message);
121
- * console.error('Details:', error.detail);
122
- * }
123
- * }
124
- * ```
125
- */
126
- var ContainerError = class extends BaseError {};
127
- /**
128
125
  * Error thrown when attempting to register a dependency that has already been instantiated.
129
126
  *
130
127
  * This error occurs when calling `container.register()` for a tag that has already been instantiated.
131
128
  * Registration must happen before any instantiation occurs, as cached instances would still be used
132
129
  * by existing dependencies.
133
130
  */
134
- var DependencyAlreadyInstantiatedError = class extends ContainerError {};
131
+ var DependencyAlreadyInstantiatedError = class extends SandlyError {};
135
132
  /**
136
133
  * Error thrown when attempting to use a container that has been destroyed.
137
134
  *
@@ -139,7 +136,7 @@ var DependencyAlreadyInstantiatedError = class extends ContainerError {};
139
136
  * on a container that has already been destroyed. It indicates a programming error where the container
140
137
  * is being used after it has been destroyed.
141
138
  */
142
- var ContainerDestroyedError = class extends ContainerError {};
139
+ var ContainerDestroyedError = class extends SandlyError {};
143
140
  /**
144
141
  * Error thrown when attempting to retrieve a dependency that hasn't been registered.
145
142
  *
@@ -160,7 +157,7 @@ var ContainerDestroyedError = class extends ContainerError {};
160
157
  * }
161
158
  * ```
162
159
  */
163
- var UnknownDependencyError = class extends ContainerError {
160
+ var UnknownDependencyError = class extends SandlyError {
164
161
  /**
165
162
  * @internal
166
163
  * Creates an UnknownDependencyError for the given tag.
@@ -201,7 +198,7 @@ var UnknownDependencyError = class extends ContainerError {
201
198
  * }
202
199
  * ```
203
200
  */
204
- var CircularDependencyError = class extends ContainerError {
201
+ var CircularDependencyError = class extends SandlyError {
205
202
  /**
206
203
  * @internal
207
204
  * Creates a CircularDependencyError with the dependency chain information.
@@ -223,6 +220,9 @@ var CircularDependencyError = class extends ContainerError {
223
220
  * This wraps the original error with additional context about which dependency
224
221
  * failed to be created. The original error is preserved as the `cause` property.
225
222
  *
223
+ * When dependencies are nested (A depends on B depends on C), and C's factory throws,
224
+ * you get nested DependencyCreationErrors. Use `getRootCause()` to get the original error.
225
+ *
226
226
  * @example Factory throwing error
227
227
  * ```typescript
228
228
  * class DatabaseService extends Tag.Service('DatabaseService') {}
@@ -240,8 +240,22 @@ var CircularDependencyError = class extends ContainerError {
240
240
  * }
241
241
  * }
242
242
  * ```
243
+ *
244
+ * @example Getting root cause from nested errors
245
+ * ```typescript
246
+ * // ServiceA -> ServiceB -> ServiceC (ServiceC throws)
247
+ * try {
248
+ * await container.resolve(ServiceA);
249
+ * } catch (error) {
250
+ * if (error instanceof DependencyCreationError) {
251
+ * console.error('Top-level error:', error.message); // "Error creating instance of ServiceA"
252
+ * const rootCause = error.getRootCause();
253
+ * console.error('Root cause:', rootCause); // Original error from ServiceC
254
+ * }
255
+ * }
256
+ * ```
243
257
  */
244
- var DependencyCreationError = class extends ContainerError {
258
+ var DependencyCreationError = class DependencyCreationError extends SandlyError {
245
259
  /**
246
260
  * @internal
247
261
  * Creates a DependencyCreationError wrapping the original factory error.
@@ -255,6 +269,31 @@ var DependencyCreationError = class extends ContainerError {
255
269
  detail: { tag: Tag.id(tag) }
256
270
  });
257
271
  }
272
+ /**
273
+ * Traverses the error chain to find the root cause error.
274
+ *
275
+ * When dependencies are nested, each level wraps the error in a DependencyCreationError.
276
+ * This method unwraps all the layers to get to the original error that started the failure.
277
+ *
278
+ * @returns The root cause error (not a DependencyCreationError unless that's the only error)
279
+ *
280
+ * @example
281
+ * ```typescript
282
+ * try {
283
+ * await container.resolve(UserService);
284
+ * } catch (error) {
285
+ * if (error instanceof DependencyCreationError) {
286
+ * const rootCause = error.getRootCause();
287
+ * console.error('Root cause:', rootCause);
288
+ * }
289
+ * }
290
+ * ```
291
+ */
292
+ getRootCause() {
293
+ let current = this.cause;
294
+ while (current instanceof DependencyCreationError && current.cause !== void 0) current = current.cause;
295
+ return current;
296
+ }
258
297
  };
259
298
  /**
260
299
  * Error thrown when one or more finalizers fail during container destruction.
@@ -275,7 +314,7 @@ var DependencyCreationError = class extends ContainerError {
275
314
  * }
276
315
  * ```
277
316
  */
278
- var DependencyFinalizationError = class extends ContainerError {
317
+ var DependencyFinalizationError = class extends SandlyError {
279
318
  /**
280
319
  * @internal
281
320
  * Creates a DependencyFinalizationError aggregating multiple finalizer failures.
@@ -283,22 +322,44 @@ var DependencyFinalizationError = class extends ContainerError {
283
322
  * @param errors - Array of errors thrown by individual finalizers
284
323
  */
285
324
  constructor(errors) {
286
- const lambdaErrors = errors.map((error) => BaseError.ensure(error));
325
+ const lambdaErrors = errors.map((error) => SandlyError.ensure(error));
287
326
  super("Error destroying dependency container", {
288
327
  cause: errors[0],
289
328
  detail: { errors: lambdaErrors.map((error) => error.dump()) }
290
329
  });
330
+ this.errors = errors;
331
+ }
332
+ /**
333
+ * Returns the root causes of the errors that occurred during finalization.
334
+ *
335
+ * @returns An array of the errors that occurred during finalization.
336
+ * You can expect at least one error in the array.
337
+ */
338
+ getRootCauses() {
339
+ return this.errors;
291
340
  }
292
341
  };
293
342
 
294
343
  //#endregion
295
344
  //#region src/container.ts
296
345
  /**
297
- * AsyncLocalStorage instance used to track the dependency resolution chain.
298
- * This enables detection of circular dependencies during async dependency resolution.
346
+ * Internal implementation of ResolutionContext that carries the resolution chain
347
+ * for circular dependency detection.
299
348
  * @internal
300
349
  */
301
- const resolutionChain = new AsyncLocalStorage();
350
+ var ResolutionContextImpl = class {
351
+ constructor(resolveFn) {
352
+ this.resolveFn = resolveFn;
353
+ }
354
+ async resolve(tag) {
355
+ return this.resolveFn(tag);
356
+ }
357
+ async resolveAll(...tags) {
358
+ const promises = tags.map((tag) => this.resolve(tag));
359
+ const results = await Promise.all(promises);
360
+ return results;
361
+ }
362
+ };
302
363
  const ContainerTypeId = Symbol.for("sandly/Container");
303
364
  /**
304
365
  * A type-safe dependency injection container that manages service instantiation,
@@ -487,8 +548,9 @@ var Container = class Container {
487
548
  this.factories.set(tag, spec);
488
549
  this.finalizers.delete(tag);
489
550
  } else {
490
- this.factories.set(tag, spec.factory);
491
- this.finalizers.set(tag, spec.finalizer);
551
+ this.factories.set(tag, spec.create.bind(spec));
552
+ if (spec.cleanup) this.finalizers.set(tag, spec.cleanup.bind(spec));
553
+ else this.finalizers.delete(tag);
492
554
  }
493
555
  return this;
494
556
  }
@@ -526,8 +588,8 @@ var Container = class Container {
526
588
  * and cached for subsequent calls. The method is async-safe and handles concurrent
527
589
  * requests for the same dependency correctly.
528
590
  *
529
- * The method performs circular dependency detection using AsyncLocalStorage to track
530
- * the resolution chain across async boundaries.
591
+ * The method performs circular dependency detection by tracking the resolution chain
592
+ * through the resolution context.
531
593
  *
532
594
  * @template T - The dependency tag type (must be registered in this container)
533
595
  * @param tag - The dependency tag to retrieve
@@ -570,21 +632,30 @@ var Container = class Container {
570
632
  * ```
571
633
  */
572
634
  async resolve(tag) {
635
+ return this.resolveInternal(tag, []);
636
+ }
637
+ /**
638
+ * Internal resolution method that tracks the dependency chain for circular dependency detection.
639
+ * Can be overridden by subclasses (e.g., ScopedContainer) to implement custom resolution logic.
640
+ * @internal
641
+ */
642
+ resolveInternal(tag, chain) {
573
643
  if (this.isDestroyed) throw new ContainerDestroyedError("Cannot resolve dependencies from a destroyed container");
574
644
  const cached = this.cache.get(tag);
575
645
  if (cached !== void 0) return cached;
576
- const currentChain = resolutionChain.getStore() ?? [];
577
- if (currentChain.includes(tag)) throw new CircularDependencyError(tag, currentChain);
646
+ if (chain.includes(tag)) throw new CircularDependencyError(tag, chain);
578
647
  const factory = this.factories.get(tag);
579
648
  if (factory === void 0) throw new UnknownDependencyError(tag);
580
- const instancePromise = resolutionChain.run([...currentChain, tag], async () => {
649
+ const newChain = [...chain, tag];
650
+ const context = new ResolutionContextImpl((tag$1) => this.resolveInternal(tag$1, newChain));
651
+ const instancePromise = (async () => {
581
652
  try {
582
- const instance = await factory(this);
653
+ const instance = await factory(context);
583
654
  return instance;
584
655
  } catch (error) {
585
656
  throw new DependencyCreationError(tag, error);
586
657
  }
587
- }).catch((error) => {
658
+ })().catch((error) => {
588
659
  this.cache.delete(tag);
589
660
  throw error;
590
661
  });
@@ -638,66 +709,15 @@ var Container = class Container {
638
709
  return results;
639
710
  }
640
711
  /**
641
- * Copies all registrations from this container to a target container.
642
- *
643
- * @internal
644
- * @param target - The container to copy registrations to
645
- * @throws {ContainerDestroyedError} If this container has been destroyed
646
- */
647
- copyTo(target) {
648
- if (this.isDestroyed) throw new ContainerDestroyedError("Cannot copy registrations from a destroyed container");
649
- for (const [tag, factory] of this.factories) {
650
- const finalizer = this.finalizers.get(tag);
651
- if (finalizer) target.register(tag, {
652
- factory,
653
- finalizer
654
- });
655
- else target.register(tag, factory);
656
- }
657
- }
658
- /**
659
- * Creates a new container by merging this container's registrations with another container.
660
- *
661
- * This method creates a new container that contains all registrations from both containers.
662
- * If there are conflicts (same dependency registered in both containers), this
663
- * container's registration will take precedence.
664
- *
665
- * **Important**: Only the registrations are copied, not any cached instances.
666
- * The new container starts with an empty instance cache.
667
- *
668
- * @param other - The container to merge with
669
- * @returns A new container with combined registrations
670
- * @throws {ContainerDestroyedError} If this container has been destroyed
671
- *
672
- * @example Merging containers
673
- * ```typescript
674
- * const container1 = Container.empty()
675
- * .register(DatabaseService, () => new DatabaseService());
676
- *
677
- * const container2 = Container.empty()
678
- * .register(UserService, () => new UserService());
679
- *
680
- * const merged = container1.merge(container2);
681
- * // merged has both DatabaseService and UserService
682
- * ```
683
- */
684
- merge(other) {
685
- if (this.isDestroyed) throw new ContainerDestroyedError("Cannot merge from a destroyed container");
686
- const merged = new Container();
687
- other.copyTo(merged);
688
- this.copyTo(merged);
689
- return merged;
690
- }
691
- /**
692
712
  * Destroys all instantiated dependencies by calling their finalizers and makes the container unusable.
693
713
  *
694
714
  * **Important: After calling destroy(), the container becomes permanently unusable.**
695
- * Any subsequent calls to register(), get(), or destroy() will throw a ContainerError.
715
+ * Any subsequent calls to register(), get(), or destroy() will throw a DependencyFinalizationError.
696
716
  * This ensures proper cleanup and prevents runtime errors from accessing destroyed resources.
697
717
  *
698
718
  * All finalizers for instantiated dependencies are called concurrently using Promise.allSettled()
699
719
  * for maximum cleanup performance.
700
- * If any finalizers fail, all errors are collected and a DependencyContainerFinalizationError
720
+ * If any finalizers fail, all errors are collected and a DependencyFinalizationError
701
721
  * is thrown containing details of all failures.
702
722
  *
703
723
  * **Finalizer Concurrency:** Finalizers run concurrently, so there are no ordering guarantees.
@@ -960,7 +980,14 @@ var ScopedContainer = class ScopedContainer extends Container {
960
980
  * 4. If no parent or parent doesn't have it, throw UnknownDependencyError
961
981
  */
962
982
  async resolve(tag) {
963
- if (this.factories.has(tag)) return super.resolve(tag);
983
+ return this.resolveInternal(tag, []);
984
+ }
985
+ /**
986
+ * Internal resolution with delegation logic for scoped containers.
987
+ * @internal
988
+ */
989
+ resolveInternal(tag, chain) {
990
+ if (this.factories.has(tag)) return super.resolveInternal(tag, chain);
964
991
  if (this.parent !== null) return this.parent.resolve(tag);
965
992
  throw new UnknownDependencyError(tag);
966
993
  }
@@ -992,24 +1019,6 @@ var ScopedContainer = class ScopedContainer extends Container {
992
1019
  if (allFailures.length > 0) throw new DependencyFinalizationError(allFailures);
993
1020
  }
994
1021
  /**
995
- * Creates a new scoped container by merging this container's registrations with another container.
996
- *
997
- * This method overrides the base Container.merge to return a ScopedContainer instead of a regular Container.
998
- * The resulting scoped container contains all registrations from both containers and becomes a root scope
999
- * (no parent) with the scope name from this container.
1000
- *
1001
- * @param other - The container to merge with
1002
- * @returns A new ScopedContainer with combined registrations
1003
- * @throws {ContainerDestroyedError} If this container has been destroyed
1004
- */
1005
- merge(other) {
1006
- if (this.isDestroyed) throw new ContainerDestroyedError("Cannot merge from a destroyed container");
1007
- const merged = new ScopedContainer(null, this.scope);
1008
- other.copyTo(merged);
1009
- this.copyTo(merged);
1010
- return merged;
1011
- }
1012
- /**
1013
1022
  * Creates a child scoped container.
1014
1023
  *
1015
1024
  * Child containers inherit access to parent dependencies but maintain
@@ -1022,52 +1031,6 @@ var ScopedContainer = class ScopedContainer extends Container {
1022
1031
  return child;
1023
1032
  }
1024
1033
  };
1025
- /**
1026
- * Converts a regular container into a scoped container, copying all registrations.
1027
- *
1028
- * This function creates a new ScopedContainer instance and copies all factory functions
1029
- * and finalizers from the source container. The resulting scoped container becomes a root
1030
- * scope (no parent) with all the same dependency registrations.
1031
- *
1032
- * **Important**: Only the registrations are copied, not any cached instances.
1033
- * The new scoped container starts with an empty instance cache.
1034
- *
1035
- * @param container - The container to convert to a scoped container
1036
- * @param scope - A string or symbol identifier for this scope (used for debugging)
1037
- * @returns A new ScopedContainer instance with all registrations copied from the source container
1038
- * @throws {ContainerDestroyedError} If the source container has been destroyed
1039
- *
1040
- * @example Converting a regular container to scoped
1041
- * ```typescript
1042
- * import { container, scoped } from 'sandly';
1043
- *
1044
- * const appContainer = Container.empty()
1045
- * .register(DatabaseService, () => new DatabaseService())
1046
- * .register(ConfigService, () => new ConfigService());
1047
- *
1048
- * const scopedAppContainer = scoped(appContainer, 'app');
1049
- *
1050
- * // Create child scopes
1051
- * const requestContainer = scopedAppContainer.child('request');
1052
- * ```
1053
- *
1054
- * @example Copying complex registrations
1055
- * ```typescript
1056
- * const baseContainer = Container.empty()
1057
- * .register(DatabaseService, () => new DatabaseService())
1058
- * .register(UserService, {
1059
- * factory: async (ctx) => new UserService(await ctx.resolve(DatabaseService)),
1060
- * finalizer: (service) => service.cleanup()
1061
- * });
1062
- *
1063
- * const scopedContainer = scoped(baseContainer, 'app');
1064
- * // scopedContainer now has all the same registrations with finalizers preserved
1065
- * ```
1066
- */
1067
- function scoped(container, scope) {
1068
- const emptyScoped = ScopedContainer.empty(scope);
1069
- return emptyScoped.merge(container);
1070
- }
1071
1034
 
1072
1035
  //#endregion
1073
1036
  //#region src/service.ts
@@ -1212,14 +1175,14 @@ function service(tag, spec) {
1212
1175
  * DatabaseService,
1213
1176
  * {
1214
1177
  * dependencies: ['postgresql://localhost:5432/mydb'],
1215
- * finalizer: (service) => service.disconnect() // Finalizer for cleanup
1178
+ * cleanup: (service) => service.disconnect() // Finalizer for cleanup
1216
1179
  * }
1217
1180
  * );
1218
1181
  * ```
1219
1182
  */
1220
1183
  function autoService(tag, spec) {
1221
1184
  if (Array.isArray(spec)) spec = { dependencies: spec };
1222
- const factory = async (ctx) => {
1185
+ const create = async (ctx) => {
1223
1186
  const diDeps = [];
1224
1187
  for (const dep of spec.dependencies) if (Tag.isTag(dep)) diDeps.push(dep);
1225
1188
  const resolved = await ctx.resolveAll(...diDeps);
@@ -1229,11 +1192,10 @@ function autoService(tag, spec) {
1229
1192
  else args.push(dep);
1230
1193
  return new tag(...args);
1231
1194
  };
1232
- const finalSpec = spec.finalizer ? {
1233
- factory,
1234
- finalizer: spec.finalizer
1235
- } : factory;
1236
- return service(tag, finalSpec);
1195
+ return service(tag, {
1196
+ create,
1197
+ cleanup: spec.cleanup
1198
+ });
1237
1199
  }
1238
1200
 
1239
1201
  //#endregion
@@ -1261,4 +1223,4 @@ function value(tag, constantValue) {
1261
1223
  }
1262
1224
 
1263
1225
  //#endregion
1264
- export { CircularDependencyError, Container, ContainerDestroyedError, ContainerError, DependencyAlreadyInstantiatedError, DependencyCreationError, DependencyFinalizationError, InjectSource, Layer, ScopedContainer, Tag, UnknownDependencyError, autoService, layer, scoped, service, value };
1226
+ export { CircularDependencyError, Container, ContainerDestroyedError, DependencyAlreadyInstantiatedError, DependencyCreationError, DependencyFinalizationError, InjectSource, Layer, SandlyError, ScopedContainer, Tag, UnknownDependencyError, autoService, layer, service, value };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sandly",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "keywords": [
5
5
  "typescript",
6
6
  "sandly",