@cqrs-ddd/pipeline-resilience 0.5.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.
Files changed (40) hide show
  1. package/COMMERCIAL_LICENSE.txt +34 -0
  2. package/LICENSE +661 -0
  3. package/README.md +36 -0
  4. package/dist/errors/resilience-configuration.error.d.ts +13 -0
  5. package/dist/errors/resilience-configuration.error.d.ts.map +1 -0
  6. package/dist/errors/resilience-configuration.error.js +19 -0
  7. package/dist/errors/resilience-configuration.error.js.map +1 -0
  8. package/dist/errors/resilience-policy-configuration.error.d.ts +12 -0
  9. package/dist/errors/resilience-policy-configuration.error.d.ts.map +1 -0
  10. package/dist/errors/resilience-policy-configuration.error.js +17 -0
  11. package/dist/errors/resilience-policy-configuration.error.js.map +1 -0
  12. package/dist/helpers/policy-factory.d.ts +34 -0
  13. package/dist/helpers/policy-factory.d.ts.map +1 -0
  14. package/dist/helpers/policy-factory.js +189 -0
  15. package/dist/helpers/policy-factory.js.map +1 -0
  16. package/dist/helpers/resilience-context.d.ts +38 -0
  17. package/dist/helpers/resilience-context.d.ts.map +1 -0
  18. package/dist/helpers/resilience-context.js +45 -0
  19. package/dist/helpers/resilience-context.js.map +1 -0
  20. package/dist/helpers/resilience.intent.d.ts +29 -0
  21. package/dist/helpers/resilience.intent.d.ts.map +1 -0
  22. package/dist/helpers/resilience.intent.js +21 -0
  23. package/dist/helpers/resilience.intent.js.map +1 -0
  24. package/dist/index.d.ts +10 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +12 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/interfaces/resilience-options.interface.d.ts +326 -0
  29. package/dist/interfaces/resilience-options.interface.d.ts.map +1 -0
  30. package/dist/interfaces/resilience-options.interface.js +3 -0
  31. package/dist/interfaces/resilience-options.interface.js.map +1 -0
  32. package/dist/resilience-policies.d.ts +48 -0
  33. package/dist/resilience-policies.d.ts.map +1 -0
  34. package/dist/resilience-policies.js +75 -0
  35. package/dist/resilience-policies.js.map +1 -0
  36. package/dist/resilience.behavior.d.ts +64 -0
  37. package/dist/resilience.behavior.d.ts.map +1 -0
  38. package/dist/resilience.behavior.js +159 -0
  39. package/dist/resilience.behavior.js.map +1 -0
  40. package/package.json +68 -0
@@ -0,0 +1,159 @@
1
+ /* Copyright (C) 2026-present Aristotelis — see repository license. */
2
+ import { PIPELINE_BEHAVIOR_CONTRACT, } from '@cqrs-ddd/pipeline';
3
+ import { ResilienceConfigurationError } from './errors/resilience-configuration.error.js';
4
+ import { buildResiliencePolicy, } from './helpers/policy-factory.js';
5
+ import { runWithResilienceAbortSignal, runWithResilienceRequest, } from './helpers/resilience-context.js';
6
+ function getResilienceSafetyIssues(options, requestKind) {
7
+ if (!options || options.policy)
8
+ return [];
9
+ const issues = [];
10
+ const dependencyLayers = options;
11
+ if (dependencyLayers.circuitBreaker !== undefined ||
12
+ dependencyLayers.fallback !== undefined) {
13
+ issues.push({
14
+ message: 'circuitBreaker and fallback are not applied around a whole handler; they belong to a named policy of an outbound dependency',
15
+ fix: 'Declare them as a named policy, new ResiliencePolicies({ <name>: { ... } }), and run the outbound call through it.',
16
+ });
17
+ }
18
+ const classifiesErrors = typeof options.handle === 'function' || options.handleAllErrors === true;
19
+ if (options.retry && !classifiesErrors) {
20
+ issues.push({
21
+ message: 'retry requires handle(error) or explicit handleAllErrors: true',
22
+ fix: 'Specify handle: (err) => boolean or handleAllErrors: true in ResilienceBehavior options.',
23
+ });
24
+ }
25
+ if (options.retry &&
26
+ requestKind !== 'query' &&
27
+ options.retry.replaySafe !== true) {
28
+ issues.push({
29
+ message: `retry on non-query handler (${requestKind}) replays downstream work; commands/events must set retry.replaySafe: true`,
30
+ fix: 'Set retry: { ...retry, replaySafe: true } after verifying handler side effects are idempotent or transactional.',
31
+ });
32
+ }
33
+ if (options.timeout &&
34
+ requestKind !== 'query' &&
35
+ options.timeout.strategy !== 'cooperative' &&
36
+ options.timeout.replaySafe !== true) {
37
+ issues.push({
38
+ message: `aggressive timeout on non-query handler (${requestKind}) answers the caller while the handler keeps running, so a retry can overlap its side effects`,
39
+ fix: "Use timeout: { ...timeout, strategy: 'cooperative' } and pass getResilienceAbortSignal() to cancellable work, or set timeout.replaySafe: true after verifying overlapping executions are safe.",
40
+ });
41
+ }
42
+ return issues;
43
+ }
44
+ /**
45
+ * Pipeline behavior that wraps each command / query / event handler in a
46
+ * cockatiel resilience policy — the layers that make sense around a whole
47
+ * handler: retry, timeout and bulkhead.
48
+ *
49
+ * A circuit breaker and a fallback belong to an outbound dependency, not to a
50
+ * handler: declare them on a named policy (`new ResiliencePolicies({ ... })`)
51
+ * and use it in the adapter through {@link ResiliencePolicies}. The behavior
52
+ * contract rejects them here.
53
+ *
54
+ * Resolution of the effective options for a handler where this behavior is
55
+ * attached:
56
+ * 1. Application-wide defaults, the first constructor argument.
57
+ * 2. Per-handler options from `pipeline.wrap(options, [ResilienceBehavior, { ... }])`,
58
+ * shallow-merged on top of the defaults (handler keys win).
59
+ *
60
+ * Policies are built **lazily on first invocation and cached per handler**, so
61
+ * a stateful layer (bulkhead) correctly shares state across
62
+ * every request to that handler. When no options resolve, the behavior caches
63
+ * that result and passes subsequent invocations directly to `next()` without
64
+ * constructing or executing a cockatiel policy.
65
+ *
66
+ * ### Replay / error-classification safety
67
+ *
68
+ * A handler-level retry calls `next()` again, which means the complete
69
+ * downstream pipeline and handler are replayed. To avoid accidental duplicate
70
+ * side effects, command/event retries must explicitly set
71
+ * `retry.replaySafe: true`. A retry must also define which errors are transient via `handle(error)`, unless the caller
72
+ * intentionally opts into `handleAllErrors: true`. An `aggressive` timeout
73
+ * (the default strategy) on a command/event answers the caller while the
74
+ * handler keeps running, so it requires `strategy: 'cooperative'` or
75
+ * `timeout.replaySafe: true`.
76
+ *
77
+ * Timeout and bulkhead-only policies do not require an error classifier because
78
+ * they do not decide which application errors are retryable.
79
+ * A custom pre-built Cockatiel `policy` also bypasses the declarative safety
80
+ * checks because the caller owns its semantics directly.
81
+ */
82
+ export class ResilienceBehavior {
83
+ defaultOptions;
84
+ static [PIPELINE_BEHAVIOR_CONTRACT] = {
85
+ validate: (context) => {
86
+ const options = context.effectiveOptions;
87
+ const issues = getResilienceSafetyIssues(options, context.requestKind);
88
+ if (issues.length === 0)
89
+ return undefined;
90
+ return issues.map((issue) => ({
91
+ handlerName: context.handlerName,
92
+ behaviorName: ResilienceBehavior.name,
93
+ message: issue.message,
94
+ fix: issue.fix,
95
+ }));
96
+ },
97
+ };
98
+ logger;
99
+ /**
100
+ * Per-handler policy cache. `null` means "resolved, but nothing configured"
101
+ * (pass-through), distinct from `undefined` ("not yet resolved").
102
+ */
103
+ policyCache = new Map();
104
+ constructor(defaultOptions, logger) {
105
+ this.defaultOptions = defaultOptions;
106
+ this.logger = logger ?? console;
107
+ }
108
+ async handle(context, next) {
109
+ const policy = this.resolvePolicy(context);
110
+ if (!policy)
111
+ return next();
112
+ // The policy is shared across every request that reaches this handler, so
113
+ // the request's own labels travel with the execution rather than being
114
+ // captured when the policy was built.
115
+ return runWithResilienceRequest({ requestName: context.requestName, handlerName: context.handlerName }, () => policy.execute((policyContext) => {
116
+ // Cockatiel supplies the effective AbortSignal (including timeout
117
+ // cancellation) to each execute callback. Bind it to this attempt's
118
+ // async execution so an aggressive timeout followed by a retry cannot
119
+ // replace the signal still observed by work from the timed-out attempt.
120
+ return runWithResilienceAbortSignal(policyContext.signal, next);
121
+ }));
122
+ }
123
+ /** Resolves, validates, and caches the composed policy for the handler in `context`. */
124
+ resolvePolicy(context) {
125
+ const cached = this.policyCache.get(context.handlerType);
126
+ if (cached !== undefined)
127
+ return cached;
128
+ const handlerOptions = context.getBehaviorOptions(ResilienceBehavior);
129
+ const effective = this.resolveEffectiveOptions(handlerOptions);
130
+ this.assertSafeConfiguration(context, effective);
131
+ const policy = effective
132
+ ? buildResiliencePolicy(effective, {
133
+ logger: this.logger,
134
+ requestName: context.requestName,
135
+ handlerName: context.handlerName,
136
+ })
137
+ : null;
138
+ this.policyCache.set(context.handlerType, policy);
139
+ return policy;
140
+ }
141
+ /**
142
+ * Rejects ambiguous whole-handler resilience configurations before Cockatiel
143
+ * policy creation. This turns replay/error-classification assumptions into an
144
+ * explicit application decision instead of a hidden default.
145
+ */
146
+ assertSafeConfiguration(context, options) {
147
+ const issues = getResilienceSafetyIssues(options, context.requestKind);
148
+ if (issues.length > 0) {
149
+ throw new ResilienceConfigurationError(context.requestName, context.requestKind, issues[0].message);
150
+ }
151
+ }
152
+ /** Shallow-merges pipeline-level options over the constructor defaults. */
153
+ resolveEffectiveOptions(options) {
154
+ if (!this.defaultOptions && !options)
155
+ return undefined;
156
+ return { ...this.defaultOptions, ...options };
157
+ }
158
+ }
159
+ //# sourceMappingURL=resilience.behavior.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resilience.behavior.js","sourceRoot":"","sources":["../src/resilience.behavior.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@cqrs-ddd/pipeline-resilience",
3
+ "version": "0.5.0",
4
+ "description": "Retry, timeout, circuit breaker, bulkhead and fallback policies (cockatiel) for @cqrs-ddd/pipeline",
5
+ "author": "Aristotelis",
6
+ "license": "SEE LICENSE IN LICENSE",
7
+ "type": "module",
8
+ "engines": {
9
+ "node": ">=22.12.0"
10
+ },
11
+ "keywords": [
12
+ "cqrs",
13
+ "pipeline",
14
+ "resilience",
15
+ "fault-tolerance",
16
+ "retry",
17
+ "circuit-breaker",
18
+ "timeout",
19
+ "bulkhead",
20
+ "fallback",
21
+ "cockatiel"
22
+ ],
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/aristoteliss/ddd-cqrs.git",
26
+ "directory": "packages/pipeline-resilience"
27
+ },
28
+ "homepage": "https://aristoteliss.github.io/ddd-cqrs/packages/pipeline-resilience/",
29
+ "bugs": {
30
+ "url": "https://github.com/aristoteliss/ddd-cqrs/issues"
31
+ },
32
+ "exports": {
33
+ ".": {
34
+ "types": "./dist/index.d.ts",
35
+ "default": "./dist/index.js"
36
+ },
37
+ "./package.json": "./package.json"
38
+ },
39
+ "main": "dist/index.js",
40
+ "types": "dist/index.d.ts",
41
+ "files": [
42
+ "dist",
43
+ "LICENSE",
44
+ "COMMERCIAL_LICENSE.txt"
45
+ ],
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "peerDependencies": {
50
+ "cockatiel": "^4.0.0",
51
+ "@cqrs-ddd/pipeline": "^0.5.0"
52
+ },
53
+ "devDependencies": {
54
+ "@vitest/coverage-v8": "^5.0.3",
55
+ "cockatiel": "^4.0.0",
56
+ "vitest": "^5.0.3",
57
+ "@cqrs-ddd/pipeline": "0.5.0"
58
+ },
59
+ "scripts": {
60
+ "build": "tsc -p tsconfig.build.json",
61
+ "build:watch": "tsc -p tsconfig.build.json --watch",
62
+ "clean": "rm -rf dist tsconfig.tsbuildinfo",
63
+ "rebuild": "pnpm run clean && pnpm run build",
64
+ "lint": "tsc --noEmit",
65
+ "test": "vitest run",
66
+ "test:watch": "vitest"
67
+ }
68
+ }