@mnci/az-durable 0.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 +226 -0
- package/dist/eslint-plugin.d.ts +1 -0
- package/dist/eslint-plugin.esm.js +363 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.esm.js +1424 -0
- package/dist/src/activity.d.ts +92 -0
- package/dist/src/client.d.ts +23 -0
- package/dist/src/eslint-plugin.d.ts +46 -0
- package/dist/src/events.d.ts +81 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/orchestration.d.ts +117 -0
- package/dist/src/parallel.d.ts +71 -0
- package/dist/src/registry.d.ts +43 -0
- package/dist/src/retry.d.ts +45 -0
- package/dist/src/rules/noNondeterministicOrchestrator.d.ts +17 -0
- package/dist/src/rules/noUntypedActivityHandler.d.ts +27 -0
- package/dist/src/rules/requireYieldStar.d.ts +22 -0
- package/dist/src/rules/shared.d.ts +96 -0
- package/dist/src/status.d.ts +32 -0
- package/dist/src/testing.d.ts +122 -0
- package/dist/src/time.d.ts +79 -0
- package/dist/src/types.d.ts +96 -0
- package/dist/testing.d.ts +1 -0
- package/dist/testing.esm.js +225 -0
- package/package.json +54 -0
package/README.md
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# @mnci/az-durable
|
|
2
|
+
|
|
3
|
+
Compile-time type safety across the Azure Durable Functions
|
|
4
|
+
orchestrator/activity boundary.
|
|
5
|
+
|
|
6
|
+
The SDK types an activity call as `any` in and `any` out. Every input you pass
|
|
7
|
+
and every result you read is unchecked, so a shape change on one side of the
|
|
8
|
+
boundary surfaces at runtime, in an orchestration that may already have
|
|
9
|
+
instances in flight. This package makes both ends typed, with no code
|
|
10
|
+
generation, no fork, and nothing to keep in sync.
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
// Without: `article` is any. `article.titel` compiles.
|
|
14
|
+
const article = yield context.df.callActivity('FetchArticle', { id })
|
|
15
|
+
|
|
16
|
+
// With: `article` is FetchArticle's real return type. `article.titel` does not.
|
|
17
|
+
const article = yield * callActivity(context, fetchArticle, { id })
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install @mnci/az-durable
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`durable-functions` (>=3 <4) and `@azure/functions` (>=4 <5) are **peer**
|
|
27
|
+
dependencies — the ones your Function App already has. This package declares no
|
|
28
|
+
dependencies of its own.
|
|
29
|
+
|
|
30
|
+
## The mechanism, in one paragraph
|
|
31
|
+
|
|
32
|
+
A generator has exactly one `TNext` shared by every `yield` in it, so
|
|
33
|
+
`const x = yield callActivity(...)` can never be typed per call — whatever `x`
|
|
34
|
+
is, it is the same type for every yield in the orchestration. `yield *` is
|
|
35
|
+
different: it returns the *delegated* generator's `TReturn`, which **is** per
|
|
36
|
+
call. That is the whole trick, and it is why every scheduling helper here is a
|
|
37
|
+
generator you delegate to rather than a value you yield. Getting it wrong is a
|
|
38
|
+
compile error, not a silent `any`.
|
|
39
|
+
|
|
40
|
+
## Defining the boundary
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { defineActivity, defineOrchestration, callActivity } from '@mnci/az-durable'
|
|
44
|
+
|
|
45
|
+
export const fetchArticle = defineActivity(
|
|
46
|
+
'FetchArticle', // the registered name, verbatim
|
|
47
|
+
async (input: { id: string }) => await db.get(input.id) // types inferred from here
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
export const publish = defineOrchestration(
|
|
51
|
+
'PublishArticle',
|
|
52
|
+
function * (context, input: { id: string }) {
|
|
53
|
+
const article = yield * callActivity(context, fetchArticle, { id: input.id })
|
|
54
|
+
return { title: article.title }
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Names are string literals you write, and the package will never generate one.
|
|
60
|
+
**A name is baked into every orchestration's history**: renaming it breaks
|
|
61
|
+
in-flight instances, which resume against the new code expecting the old name.
|
|
62
|
+
Duplicate registrations throw at startup rather than silently shadowing.
|
|
63
|
+
|
|
64
|
+
### Validating input at the boundary
|
|
65
|
+
|
|
66
|
+
`context.df.getInput<T>()` is an unchecked cast — `T` is a claim the SDK never
|
|
67
|
+
verifies. It matters more here than in ordinary code, because orchestration
|
|
68
|
+
input comes back *out of the task hub*: an instance started by yesterday's
|
|
69
|
+
deploy resumes against today's code, so a shape change between deploys arrives
|
|
70
|
+
as a silently wrong object.
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
defineOrchestration('ResetSite', handler, { parse: parseResetRequest })
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Scheduling
|
|
77
|
+
|
|
78
|
+
Every helper comes in two forms: a **generator** you `yield *` for the common
|
|
79
|
+
sequential case, and a **task** you hold to run things concurrently.
|
|
80
|
+
|
|
81
|
+
| Delegate with `yield *` | Hold as a task | Produces |
|
|
82
|
+
| --- | --- | --- |
|
|
83
|
+
| `callActivity` | `activityTask` | the activity's return type |
|
|
84
|
+
| `callSubOrchestration` | `subOrchestrationTask` | the sub-orchestration's return type |
|
|
85
|
+
| `waitForEvent` | `eventTask` | the event's payload type |
|
|
86
|
+
| `sleepFor` / `sleepUntil` | `timerTask` / `timerTaskUntil` | `void` |
|
|
87
|
+
|
|
88
|
+
### Fan-out
|
|
89
|
+
|
|
90
|
+
`all` preserves a tuple positionally, and maps an array to an array:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
const [html, slug] = yield * all(context, [
|
|
94
|
+
activityTask(context, renderHtml, draft),
|
|
95
|
+
activityTask(context, buildSlug, draft.title)
|
|
96
|
+
]) // [string, string]
|
|
97
|
+
|
|
98
|
+
const results = yield * all(
|
|
99
|
+
context,
|
|
100
|
+
items.map(i => activityTask(context, deleteItem, { id: i.id }))
|
|
101
|
+
) // { deleted: boolean, bytes: number }[]
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Racing
|
|
105
|
+
|
|
106
|
+
`any` returns the **winning task**, matching the SDK. Read its value with
|
|
107
|
+
`resultOf`, and identify the winner by identity:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
const approval = eventTask(context, approved)
|
|
111
|
+
const deadline = timerTask(context, ONE_DAY_MS)
|
|
112
|
+
|
|
113
|
+
const winner = yield * any(context, [approval, deadline])
|
|
114
|
+
if (winner === deadline) {
|
|
115
|
+
return { published: false, reason: 'approval timed out' }
|
|
116
|
+
}
|
|
117
|
+
if (!deadline.isCompleted()) {
|
|
118
|
+
deadline.cancel() // REQUIRED — see below
|
|
119
|
+
}
|
|
120
|
+
const { approvedBy } = resultOf(approval)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**Cancel the losing timer.** An orchestration does not complete until every
|
|
124
|
+
scheduled timer has fired or been cancelled, so a timeout timer left pending
|
|
125
|
+
after its race is won keeps the instance alive until it expires. `timerTask`
|
|
126
|
+
returns `cancel()` and `isCompleted()` for exactly this.
|
|
127
|
+
|
|
128
|
+
### Retries
|
|
129
|
+
|
|
130
|
+
`RetryOptions` is a class in the SDK, so an object literal cannot satisfy it.
|
|
131
|
+
`retryPolicy` takes the plain object and builds a real instance:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
yield * callActivity(context, store, input, retryPolicy({
|
|
135
|
+
firstRetryIntervalInMilliseconds: 1000,
|
|
136
|
+
maxNumberOfAttempts: 3,
|
|
137
|
+
backoffCoefficient: 2
|
|
138
|
+
}))
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Restarting an eternal orchestration
|
|
142
|
+
|
|
143
|
+
History grows with every call, so a sweep over an unbounded backlog must
|
|
144
|
+
restart rather than keep going. `continueAsNew` arrives as a third handler
|
|
145
|
+
argument, because it restarts *this* orchestration and so its input is checked
|
|
146
|
+
against this orchestration's own type:
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
defineOrchestration('Cleanup', function * (context, input: Sweep, self) {
|
|
150
|
+
// ...
|
|
151
|
+
self.continueAsNew({ olderThanDays: input.olderThanDays })
|
|
152
|
+
return summary // return immediately after; the SDK requires it
|
|
153
|
+
})
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Testing
|
|
157
|
+
|
|
158
|
+
`@mnci/az-durable/testing` drives an orchestration against stubbed activities
|
|
159
|
+
with no Azure running — no host, no emulator, no storage.
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
import { runWorkflow } from '@mnci/az-durable/testing'
|
|
163
|
+
|
|
164
|
+
const run = runWorkflow(publish, { id: 'a1' }, {
|
|
165
|
+
activities: { FetchArticle: () => ({ title: 'T' }) }
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
expect(run.result).toEqual({ title: 'T' })
|
|
169
|
+
expect(run.calls.map(c => c.name)).toEqual(['FetchArticle']) // order matters
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
- **`calls` is ordered**, because reordering two activity calls is a breaking
|
|
173
|
+
change to an orchestration. That makes it directly assertable.
|
|
174
|
+
- **Returning an `Error` from a stub throws inside the orchestration**, so
|
|
175
|
+
`catch` branches and compensation paths are testable.
|
|
176
|
+
- **`raceWinner` picks the winner of an `any`**, by scheduled name (a timer is
|
|
177
|
+
`__timer`). Without it the first candidate always wins and the other branch
|
|
178
|
+
is unreachable.
|
|
179
|
+
- **`now` fixes the clock**; **`instanceId` sets the instance id.**
|
|
180
|
+
- **`continuedAsNew`** reports a restart request. The harness records it and
|
|
181
|
+
lets the run finish rather than looping — an eternal orchestration restarts
|
|
182
|
+
forever by design. The next generation is a separate `runWorkflow` call.
|
|
183
|
+
|
|
184
|
+
Stated rather than discovered later: **retry policies are not simulated** — a
|
|
185
|
+
stub returning an `Error` throws once, it does not exhaust attempts. Timers
|
|
186
|
+
complete immediately.
|
|
187
|
+
|
|
188
|
+
## Lint rules
|
|
189
|
+
|
|
190
|
+
```js
|
|
191
|
+
// eslint.config.mjs
|
|
192
|
+
import { recommended } from '@mnci/az-durable/eslint-plugin'
|
|
193
|
+
export default [recommended]
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
| Rule | Default | What it catches |
|
|
197
|
+
| --- | --- | --- |
|
|
198
|
+
| `no-untyped-activity-handler` | error | `: ActivityHandler` and friends, which are aliases for `(any, context) => any` — legal TypeScript that silently erases the handler's real signature |
|
|
199
|
+
| `no-nondeterministic-orchestrator` | error | `new Date()`, `Date.now`, `Math.random`, `crypto.randomUUID`, `process.env`, `fetch` inside an orchestration |
|
|
200
|
+
| `require-yield-star` | warn | `yield callActivity(...)` where `yield *` is meant |
|
|
201
|
+
|
|
202
|
+
`no-untyped-activity-handler` is the one that earns its keep: the annotation is
|
|
203
|
+
legal, so nothing errors — the types simply stop meaning anything. Its nastier
|
|
204
|
+
form is a middleware wrapper typed `(h: ActivityHandler) => ActivityHandler`,
|
|
205
|
+
which collapses **every** handler it wraps, so one helper can disable type
|
|
206
|
+
safety across a whole Function App.
|
|
207
|
+
|
|
208
|
+
`require-yield-star` is a warning rather than an error because the compiler
|
|
209
|
+
already rejects the code it flags (`TS2345` through `defineOrchestration`); the
|
|
210
|
+
rule only says so more clearly.
|
|
211
|
+
|
|
212
|
+
The determinism rule matches on call-site **shape** and does not follow values,
|
|
213
|
+
so an orchestration body extracted into a helper function is not caught. That
|
|
214
|
+
is a documented limit rather than a defect — following the value would need
|
|
215
|
+
type information the rule deliberately does not depend on.
|
|
216
|
+
|
|
217
|
+
## What this does not do
|
|
218
|
+
|
|
219
|
+
- **No name generation.** See above; names are yours.
|
|
220
|
+
- **Not engine-agnostic.** It is a typed layer over `durable-functions`, not an
|
|
221
|
+
abstraction that could target another engine.
|
|
222
|
+
- **No `async`/`await` orchestrators.** Durable's replay model requires
|
|
223
|
+
generators.
|
|
224
|
+
- **Not a fork or a patch.** Every call goes through the public SDK surface;
|
|
225
|
+
nothing here reads an undocumented internal.
|
|
226
|
+
- **Entity functions are not covered.**
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./src/eslint-plugin";
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal ESLint rule shapes, declared locally.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* Declared here rather than imported from `@types/eslint` or
|
|
6
|
+
* `@typescript-eslint/utils` because this package ships **zero runtime
|
|
7
|
+
* dependencies**, and a type-only dependency is still a dependency a consumer
|
|
8
|
+
* must be able to resolve. These cover exactly what the three rules use.
|
|
9
|
+
*/ /**
|
|
10
|
+
* Whether a call expression registers an orchestration.
|
|
11
|
+
*
|
|
12
|
+
* @remarks
|
|
13
|
+
* Matches both `defineOrchestration(...)` and the raw
|
|
14
|
+
* `df.app.orchestration(...)`, since an orchestration written either way has
|
|
15
|
+
* the same determinism constraints.
|
|
16
|
+
*
|
|
17
|
+
* **Heuristic, by construction.** These rules match on call-site SHAPE, so an
|
|
18
|
+
* orchestration body extracted into a helper function is not caught. That is a
|
|
19
|
+
* documented limit rather than a defect: following the value would require type
|
|
20
|
+
* information the rule deliberately does not depend on.
|
|
21
|
+
*
|
|
22
|
+
* @param node - A `CallExpression` node.
|
|
23
|
+
* @returns `true` when the call registers an orchestration.
|
|
24
|
+
* @throws Never - pure inspection.
|
|
25
|
+
* @typeParam None - this function has no generic type parameters.
|
|
26
|
+
*/ function isOrchestrationRegistration(node) {
|
|
27
|
+
var callee = node.callee;
|
|
28
|
+
if (callee === undefined) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
if (callee.type === 'Identifier' && callee.name === 'defineOrchestration') {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
// df.app.orchestration(...) — match on the trailing property, so any local
|
|
35
|
+
// alias for the namespace still matches.
|
|
36
|
+
if (callee.type === 'MemberExpression') {
|
|
37
|
+
var property = callee.property;
|
|
38
|
+
return (property === null || property === void 0 ? void 0 : property.type) === 'Identifier' && property.name === 'orchestration';
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The name a callee refers to, if it is a plain identifier or member access.
|
|
44
|
+
*
|
|
45
|
+
* @remarks
|
|
46
|
+
* For a member expression this returns the TRAILING property, so `df.now()`
|
|
47
|
+
* and `context.df.now()` both read as `now`. Callers that must distinguish a
|
|
48
|
+
* bare call from a member call check `node.type` themselves - `require-yield-star`
|
|
49
|
+
* does exactly that, because `yield c.df.callActivity(...)` is the correct raw
|
|
50
|
+
* SDK call and must not be flagged.
|
|
51
|
+
*
|
|
52
|
+
* @param node - A callee node.
|
|
53
|
+
* @returns The identifier or property name, or `undefined`.
|
|
54
|
+
* @throws Never - pure inspection.
|
|
55
|
+
* @typeParam None - this function has no generic type parameters.
|
|
56
|
+
*/ function calleeName(node) {
|
|
57
|
+
if (node === undefined) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
if (node.type === 'Identifier') {
|
|
61
|
+
return node.name;
|
|
62
|
+
}
|
|
63
|
+
if (node.type === 'MemberExpression') {
|
|
64
|
+
var property = node.property;
|
|
65
|
+
if ((property === null || property === void 0 ? void 0 : property.type) === 'Identifier') {
|
|
66
|
+
return property.name;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Global reads that differ on every replay, with the replacement to suggest. */ var FORBIDDEN_CALLS = {
|
|
73
|
+
'Date.now': 'now(context).getTime()',
|
|
74
|
+
'Math.random': 'context.df.newGuid(...) or an activity',
|
|
75
|
+
'crypto.randomUUID': 'context.df.newGuid(...)',
|
|
76
|
+
fetch: 'an activity — network calls must not run in an orchestrator',
|
|
77
|
+
axios: 'an activity — network calls must not run in an orchestrator'
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Flags non-deterministic operations inside an orchestration body.
|
|
81
|
+
*
|
|
82
|
+
* @remarks
|
|
83
|
+
* The highest-value rule in the package. Each of these returns a DIFFERENT
|
|
84
|
+
* value on every replay, and the failure is silent: the orchestration still
|
|
85
|
+
* completes, it just produces output that disagrees with its own history.
|
|
86
|
+
* Nothing in the runtime reports it.
|
|
87
|
+
*
|
|
88
|
+
* Every message names the replacement, because "this is non-deterministic" is
|
|
89
|
+
* only half of what a reader needs.
|
|
90
|
+
*
|
|
91
|
+
* Heuristic by design — see {@link isOrchestrationRegistration}.
|
|
92
|
+
*/ var noNondeterministicOrchestrator = {
|
|
93
|
+
meta: {
|
|
94
|
+
type: 'problem',
|
|
95
|
+
docs: {
|
|
96
|
+
description: 'Disallow non-deterministic operations inside an orchestration.'
|
|
97
|
+
},
|
|
98
|
+
schema: [],
|
|
99
|
+
messages: {
|
|
100
|
+
forbidden: '{{what}} is non-deterministic on replay. Use {{fix}} instead.',
|
|
101
|
+
newDate: 'new Date() is non-deterministic on replay. Use now(context) instead.',
|
|
102
|
+
processEnv: 'process.env is read at replay time and may differ between deploys. ' + 'Read it in an activity, or pass it as orchestration input.'
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
create: function create(context) {
|
|
106
|
+
var depth = 0;
|
|
107
|
+
var enter = function enter(node) {
|
|
108
|
+
if (isOrchestrationRegistration(node)) {
|
|
109
|
+
depth += 1;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
var exit = function exit(node) {
|
|
113
|
+
if (isOrchestrationRegistration(node)) {
|
|
114
|
+
depth -= 1;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
return {
|
|
118
|
+
CallExpression: function CallExpression(node) {
|
|
119
|
+
var _calleeName;
|
|
120
|
+
enter(node);
|
|
121
|
+
if (depth === 0) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
var callee = node.callee;
|
|
125
|
+
var object = callee === null || callee === void 0 ? void 0 : callee.object;
|
|
126
|
+
var full = (object === null || object === void 0 ? void 0 : object.type) === 'Identifier' ? "".concat(String(object.name), ".").concat(String(calleeName(callee))) : (_calleeName = calleeName(callee)) !== null && _calleeName !== void 0 ? _calleeName : '';
|
|
127
|
+
var fix = FORBIDDEN_CALLS[full];
|
|
128
|
+
if (fix !== undefined) {
|
|
129
|
+
context.report({
|
|
130
|
+
node: node,
|
|
131
|
+
messageId: 'forbidden',
|
|
132
|
+
data: {
|
|
133
|
+
what: full,
|
|
134
|
+
fix: fix
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
'CallExpression:exit': exit,
|
|
140
|
+
NewExpression: function NewExpression(node) {
|
|
141
|
+
var _ref;
|
|
142
|
+
if (depth === 0) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
var callee = node.callee;
|
|
146
|
+
var args = node.arguments;
|
|
147
|
+
// `new Date(someInstant)` is fine and common — only the argument-less
|
|
148
|
+
// form reads the wall clock.
|
|
149
|
+
if ((callee === null || callee === void 0 ? void 0 : callee.type) === 'Identifier' && callee.name === 'Date' && ((_ref = args === null || args === void 0 ? void 0 : args.length) !== null && _ref !== void 0 ? _ref : 0) === 0) {
|
|
150
|
+
context.report({
|
|
151
|
+
node: node,
|
|
152
|
+
messageId: 'newDate'
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
MemberExpression: function MemberExpression(node) {
|
|
157
|
+
if (depth === 0) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
var object = node.object;
|
|
161
|
+
var property = node.property;
|
|
162
|
+
if ((object === null || object === void 0 ? void 0 : object.type) === 'Identifier' && object.name === 'process' && (property === null || property === void 0 ? void 0 : property.type) === 'Identifier' && property.name === 'env') {
|
|
163
|
+
context.report({
|
|
164
|
+
node: node,
|
|
165
|
+
messageId: 'processEnv'
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/** SDK handler aliases that erase a handler's real signature. */ var ERASING_TYPES = new Set([
|
|
174
|
+
'ActivityHandler',
|
|
175
|
+
'OrchestrationHandler',
|
|
176
|
+
'FunctionHandler'
|
|
177
|
+
]);
|
|
178
|
+
/**
|
|
179
|
+
* The annotation's type name, if it is a bare type reference.
|
|
180
|
+
*
|
|
181
|
+
* @param annotation - A node carrying a `typeAnnotation`, or `undefined`.
|
|
182
|
+
* @returns The referenced type's name, or `undefined` when it is not a bare reference.
|
|
183
|
+
* @throws Never - pure inspection.
|
|
184
|
+
* @typeParam None - this function has no generic type parameters.
|
|
185
|
+
*/ function referencedName(annotation) {
|
|
186
|
+
var typeAnnotation = annotation === null || annotation === void 0 ? void 0 : annotation.typeAnnotation;
|
|
187
|
+
if ((typeAnnotation === null || typeAnnotation === void 0 ? void 0 : typeAnnotation.type) !== 'TSTypeReference') {
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
var typeName = typeAnnotation.typeName;
|
|
191
|
+
return (typeName === null || typeName === void 0 ? void 0 : typeName.type) === 'Identifier' ? typeName.name : undefined;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Flags annotations that collapse a typed handler back to `any`.
|
|
195
|
+
*
|
|
196
|
+
* @remarks
|
|
197
|
+
* **The rule that actually earns its keep**, because TypeScript cannot catch
|
|
198
|
+
* this: the annotation is legal, so nothing errors — the types simply stop
|
|
199
|
+
* meaning anything.
|
|
200
|
+
*
|
|
201
|
+
* `ActivityHandler` is an alias for `FunctionHandler`, which the SDK declares as
|
|
202
|
+
* `(triggerInput: any, context: InvocationContext) => FunctionResult<any>`. So
|
|
203
|
+
* annotating a handler with it discards the very signature `defineActivity`
|
|
204
|
+
* exists to capture, and the activity silently becomes `any` in, `any` out. The
|
|
205
|
+
* package then appears to work — every call compiles — while checking nothing.
|
|
206
|
+
*
|
|
207
|
+
* The second, nastier form is a middleware wrapper typed
|
|
208
|
+
* `(h: ActivityHandler) => ActivityHandler`. That collapses EVERY handler it
|
|
209
|
+
* wraps, so one `injectLogger` helper can quietly disable type safety across a
|
|
210
|
+
* whole Function App. The fix is to make the wrapper generic:
|
|
211
|
+
*
|
|
212
|
+
* ```ts
|
|
213
|
+
* const withLogging = <I, O>(h: (i: I, c: InvocationContext) => O) =>
|
|
214
|
+
* (i: I, c: InvocationContext): O => { c.log('...'); return h(i, c) }
|
|
215
|
+
* ```
|
|
216
|
+
*/ var noUntypedActivityHandler = {
|
|
217
|
+
meta: {
|
|
218
|
+
type: 'problem',
|
|
219
|
+
docs: {
|
|
220
|
+
description: 'Disallow handler annotations that erase inferred types.'
|
|
221
|
+
},
|
|
222
|
+
schema: [],
|
|
223
|
+
messages: {
|
|
224
|
+
erased: "Annotating with '{{name}}' erases the handler's real signature — it is an alias for " + '(triggerInput: any, context) => any, so the activity becomes `any` in and `any` out. ' + 'Drop the annotation and let defineActivity infer it.',
|
|
225
|
+
erasedWrapper: "A wrapper typed '{{name}}' collapses every handler it wraps to `any`. " + 'Make it generic: <I, O>(h: (i: I, c: InvocationContext) => O) => (i: I, c: InvocationContext): O.'
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
create: function create(context) {
|
|
229
|
+
return {
|
|
230
|
+
// const handler: ActivityHandler = ...
|
|
231
|
+
Identifier: function Identifier(node) {
|
|
232
|
+
var _node_parent_type, _node_parent, _node_parent_params, _node_parent1;
|
|
233
|
+
var name = referencedName(node.typeAnnotation);
|
|
234
|
+
if (name === undefined || !ERASING_TYPES.has(name)) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
// A function PARAMETER annotated this way is the middleware form, which
|
|
238
|
+
// is worse: it erases every handler passed through it, not just one.
|
|
239
|
+
var isParameter = ((_node_parent = node.parent) === null || _node_parent === void 0 ? void 0 : (_node_parent_type = _node_parent.type) === null || _node_parent_type === void 0 ? void 0 : _node_parent_type.startsWith('TS')) === false && ((_node_parent1 = node.parent) === null || _node_parent1 === void 0 ? void 0 : (_node_parent_params = _node_parent1.params) === null || _node_parent_params === void 0 ? void 0 : _node_parent_params.includes(node)) === true;
|
|
240
|
+
context.report({
|
|
241
|
+
node: node,
|
|
242
|
+
messageId: isParameter ? 'erasedWrapper' : 'erased',
|
|
243
|
+
data: {
|
|
244
|
+
name: name
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
/** The wrapper calls that must be delegated to, not yielded. */ var DELEGATED = new Set([
|
|
253
|
+
'callActivity',
|
|
254
|
+
'callSubOrchestration',
|
|
255
|
+
'all',
|
|
256
|
+
'any',
|
|
257
|
+
'waitForEvent',
|
|
258
|
+
'sleepFor',
|
|
259
|
+
'sleepUntil'
|
|
260
|
+
]);
|
|
261
|
+
/**
|
|
262
|
+
* Flags `yield callActivity(...)` where `yield *` is meant.
|
|
263
|
+
*
|
|
264
|
+
* @remarks
|
|
265
|
+
* **A convenience, not a safety net — and the build plan was wrong about this.**
|
|
266
|
+
* The plan described bare `yield` as compiling silently to `any`, "the
|
|
267
|
+
* difference between the package working and appearing to work". Measured
|
|
268
|
+
* against the real typings, it is a COMPILE ERROR in both registration paths:
|
|
269
|
+
* `TS2345` through `defineOrchestration` and `TS2322` through the SDK's own
|
|
270
|
+
* `OrchestrationHandler`, because these helpers return a `Generator` and
|
|
271
|
+
* yielding one where a `Task` is expected does not typecheck.
|
|
272
|
+
*
|
|
273
|
+
* The genuinely silent case is the RAW SDK — `yield context.df.callActivity(...)`
|
|
274
|
+
* returns `any` and compiles — which is the baseline this package replaces.
|
|
275
|
+
*
|
|
276
|
+
* So this rule earns its place only by reporting a clearer message than
|
|
277
|
+
* `TS2345` does. It is in `recommended` for that reason, not because anything
|
|
278
|
+
* depends on it.
|
|
279
|
+
*/ var requireYieldStar = {
|
|
280
|
+
meta: {
|
|
281
|
+
type: 'suggestion',
|
|
282
|
+
docs: {
|
|
283
|
+
description: 'Require `yield *` when calling a delegating helper.'
|
|
284
|
+
},
|
|
285
|
+
schema: [],
|
|
286
|
+
messages: {
|
|
287
|
+
useYieldStar: "Use 'yield *' rather than 'yield' with {{name}}(). Delegation is what carries the " + 'result type; a bare yield does not typecheck, but the compiler error is obscure.'
|
|
288
|
+
}
|
|
289
|
+
},
|
|
290
|
+
create: function create(context) {
|
|
291
|
+
return {
|
|
292
|
+
YieldExpression: function YieldExpression(node) {
|
|
293
|
+
if (node.delegate === true) {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
var argument = node.argument;
|
|
297
|
+
if ((argument === null || argument === void 0 ? void 0 : argument.type) !== 'CallExpression') {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
// A BARE IDENTIFIER only. `c.df.callActivity(...)` is the raw SDK call,
|
|
301
|
+
// which is correct code and must not be flagged — matching the trailing
|
|
302
|
+
// property of a member expression would flag it, which a negative
|
|
303
|
+
// fixture caught.
|
|
304
|
+
var callee = argument.callee;
|
|
305
|
+
if ((callee === null || callee === void 0 ? void 0 : callee.type) !== 'Identifier') {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
var name = callee.name;
|
|
309
|
+
if (DELEGATED.has(name)) {
|
|
310
|
+
context.report({
|
|
311
|
+
node: node,
|
|
312
|
+
messageId: 'useYieldStar',
|
|
313
|
+
data: {
|
|
314
|
+
name: name
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Every rule this plugin ships, by name.
|
|
325
|
+
*
|
|
326
|
+
* @remarks
|
|
327
|
+
* The keys are the names a config writes after the `az-durable/` prefix, so
|
|
328
|
+
* renaming one is a breaking change for any consumer's config.
|
|
329
|
+
*/ var rules = {
|
|
330
|
+
'no-nondeterministic-orchestrator': noNondeterministicOrchestrator,
|
|
331
|
+
'no-untyped-activity-handler': noUntypedActivityHandler,
|
|
332
|
+
'require-yield-star': requireYieldStar
|
|
333
|
+
};
|
|
334
|
+
/**
|
|
335
|
+
* The plugin object, for a flat config's `plugins` map.
|
|
336
|
+
*
|
|
337
|
+
* @remarks
|
|
338
|
+
* Shipped from a separate entry point so lint rules are never a runtime import
|
|
339
|
+
* of the wrapper itself.
|
|
340
|
+
*/ var plugin = {
|
|
341
|
+
rules: rules
|
|
342
|
+
};
|
|
343
|
+
/**
|
|
344
|
+
* The recommended rule set.
|
|
345
|
+
*
|
|
346
|
+
* @remarks
|
|
347
|
+
* Two errors and one warning, and the split is deliberate.
|
|
348
|
+
* `no-nondeterministic-orchestrator` and `no-untyped-activity-handler` catch
|
|
349
|
+
* failures nothing else does — silent replay corruption, and a type collapse
|
|
350
|
+
* TypeScript accepts as legal. `require-yield-star` is a warning because the
|
|
351
|
+
* compiler already rejects the code it flags; it only improves the message.
|
|
352
|
+
*/ var recommended = {
|
|
353
|
+
plugins: {
|
|
354
|
+
'az-durable': plugin
|
|
355
|
+
},
|
|
356
|
+
rules: {
|
|
357
|
+
'az-durable/no-nondeterministic-orchestrator': 'error',
|
|
358
|
+
'az-durable/no-untyped-activity-handler': 'error',
|
|
359
|
+
'az-durable/require-yield-star': 'warn'
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
export { noNondeterministicOrchestrator, noUntypedActivityHandler, plugin, recommended, requireYieldStar, rules };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./src/index";
|